TL;DR: Затем, что с ним код чище, читаемее и предсказуемее ;)
Старый объектно-ориентированный или императивный подход к программированию несёт в себе множество проблем, которые решает функциональное программирование. Даже в современной среде все до сих пор считают, что объектно-ориентированное программирование — правильное программирование, а функциональное — «для математиков и задротов», или вообще даже для варваров, которые даже не слышали об объектной и императивной «цивилизации».
Начиная с 90-х, у индустрии появился фетиш на классы, объекты, иерархии, наследование, инкапсуляцию, полиморфизм и прочие тяжкие грехи программирования. Расплодилось великое множество объектно-ориентированных языков: C++, Java, C#, JavaScript, Python, Ruby, Kotlin, Swift, TypeScript — и это только вершина айсберга. Вузы преподают только ООП. Всё остальное считают «странным» или «непрактичным». Выпускники выходят в индустрию с убеждением, что классы и наследование — это единственный путь. И они несут эту веру дальше, создавая новые и новые слои императивного ада.
В рамках «Функционального Rust» я не буду учить вас основам Rust или слишком глубоко зарываться в историю функционального программирования. Для этого есть официальная The Book, сотни вебинаров, документации, статьи, можете даже спросить ChatGPT. Моя цель — изменить ваш подход. Я покажу, как писать на Rust функционально: иммутабельно, декларативно, с композицией, без циклов и мутаций там, где можно обойтись без них. Я даже расскажу, где функциональный стиль не нужен, но таких мест гораздо меньше, чем принято думать.
Императивный или объектно-ориентированный подход к программированию несёт в себе множество проблем, и вот ключевые из них:
Проблема I: Мутабельность.
Представьте, что вы купили квартиру в старом доме. Ей 150 лет. Вы вкладываете душу и деньги: делаете дизайнерский ремонт, ставите мебель, которой позавидовал бы сам Артемий Лебедев, монтируете кухню с посудомойкой и индукционной плитой, вешаете водонагреватель, чтобы в июне не бегать на общую кухню за тёплой водой. Теперь внутри всё блестит и радует глаз.
Но это всё та же 150-летняя квартира.
Проводка — старая, алюминиевая, ещё с прошлого века. Канализация — ржавая, забитая. Водопровод — трубы, которые помнят ещё царя. Вода из крана — жёсткая, с тяжёлыми металлами. Мыться в ней — тот ещё «кайф». Подключить мощную технику — лотерея, ставка в которой — вся квартира. Стены потихоньку осыпаются. Дом могут признать аварийным в любой момент, а потом и снести.
// Императивный мутабельный Rust fn my_function(num: i32) -> i32 { let mut x = num; // Хватит // ...ещё 30 строк... x += 96; // Мутировать // ...ещё 50 строк... x // Значения }
Что здесь не так? Вы не создаёте новое значение — вы портите старое. Вы не знаете, что в итоге получится, потому что между строк что-то могло изменить x ещё раз. Код становится грязным, непредсказуемым, хрупким. Как старая проводка — одно неловкое движение, и короткое замыкание с ароматом плавленого пластика обеспечено.
Решение — иммутабельность.
Вы продаёте эту квартиру. И переезжаете в новый дом. С современной проводкой, пластиковыми трубами, фильтрами для воды. Построенный по всем стандартам качества. Где не нужно бояться, что замыкание спалит всю технику. Где вода не пахнет железом. Где дом не снесут через год.
Покупка новой квартиры — создание нового значения вместо мутирования старого. Как покупка новой машины вместо бесконечного колхоза своего 40-летнего «Жигуля». В Rust это очень хорошо продумано с помощью семантики перемещения и иммутабельности по умолчанию:
// Иммутабельность в Rust let x = 10; let new_x = process(x); // x переехал и больше не висит в памяти! // Значение просто переехало в другую квартиру.
В Rust вы не можете просто взять и поменять любое значение. Его нужно явно пометить как мутабельное с помощью ключевого слова mut.
let x = 10; // x = 20; // НЕТ! let mut y = 10; y = 20; // Разрешено
Но даже с этими знаниями, вместо того, чтобы бездумно мутировать каждое значение, остановитесь и подумайте:
Нужна ли вам мутабельность? Есть ли способ это сделать иммутабельно?
Если нет, перечитайте пункт 1.
Если уж совсем никак — мутируйте, но не позволяйте мутабельности распространяться на весь код. Изолируйте её в рамках одного блока.
Проблема II: Императивность
Некоторые до сих пор считают, что Rust — это «всего лишь C++ с проверками», и они ошибаются. Эти люди недооценивают потенциал Rust, обращаясь с ним по-старому, консервативно, раздувая и уродуя код. Представьте, что вы сидите с компьютером в баре за столиком. Вам захотелось ещё пива, но вам лень вставать, и в императивном программировании вы бы объяснили компьютеру путь до барной стойки:
// Императивный Rust computer.forward(50); computer.right(90); computer.forward(30); let beer = computer.ask("beer"); computer.grab(beer); // ...и так далее... // Куча строк. Зачем? Я просто хочу пива.
Решение — декларативность.
Вы просто говорите компьютеру «принеси мне пива»:
// Декларативный Rust — говорим «ЧТО». let beer = computer.bring_beer();
Суть декларативного синтаксиса в том, что вы описываете, ЧТО вы хотите, а не КАК это сделать. Программа сама разберётся, как получить требуемый результат, вам нужно лишь сказать, ЧТО вы хотите.
// Императивный Rust let mut squares = Vec::new(); // Мутабельно. for num in 0..10 { // Водим программу за ручку. squares.push(num * num); // Мутируем. } println!("{:?}", squares); // 5 строк. Цикл. Мутации. Грязно.
// Декларативный функциональный Rust let squares: Vec<i32> = (0..10) // Иммутабельно после завершения операции. .map(|x| x * x) // Не используем цикл — проходимся по каждому значению. .collect(); // Собираем результат в коллекцию. println!("{:?}", squares); // 4 строки, при желании сокращаются до двух. // Без мутаций. Без циклов. Чисто.
Декларативный подход экономит строки, убирая ненужные циклы делает код чище, избавляя от мутабельности делает код предсказуемее.
Проблема III: Некорректная или недостаточная обработка ошибок.
В императивном программировании все привыкли к исключениям. Бросил — поймал — забыл. В C++/Java вы просто пишете try { ... } catch { ... } и надеетесь, что никто не бросит std::exception или RuntimeException. В Python — try/except, и всё. Исключения стали ментальной моделью обработки ошибок.
Эта модель въелась в подкорку. Бросаем исключение — забываем. Потому что «где-то выше кто-то поймает». А если не поймает? Ну, упадёт. Подумаешь. В C++ упадёт, в Java упадёт, в Python упадёт. Зато писать быстро. Зато удобно. Зато не надо думать о типах ошибок.
И когда такие программисты приходят в Rust, они пытаются применить ту же модель. Но Rust — не Java. Исключений нет. Есть Result<T, E>. И тут начинается ломка. «Как это — нет исключений? А как же ошибки? А как же „поймаю где-нибудь наверху“?» Ответ Rust: «Ты будешь обрабатывать ошибку там, где она возникла. Или пробросишь её наверх явно. Но не надеяться, что кто-то другой о ней вспомнит».
Представьте, что вы купили старый тостер. Он работает. Но с вероятностью 1 к 10 вместо тостов он поджаривает вашу квартиру. Квартира сгорает дотла. Вы — без жилья. В императивном программировании никто вас не предупреждает. Вы вставляете хлеб, нажимаете кнопку и надеетесь. В функциональном программировании на коробке с тостером написано жирными буквами: «Result<Toast, Fire>». Это — не мелкий шрифт. Это знак внимания размером с Америку. Вы не можете его не заметить. Вы обязаны решить: что делать, если тостер решит спалить квартиру?
// Неправильная обработка ошибок в Rust old_toaster().unwrap(); // «Заткнись, компилятор! Всё будет окей!» old_toaster()?; // Мне просто лень. Да, это безопаснее, чем unwrap() // но всё же лучше делать другую логическую ветвь вместо такой обработки.
unwrap() — это не «я знаю, что здесь ошибки быть не может». Это «я надеюсь», а надежда — плохая стратегия. В продакшне надежда превращается в панику. А паника — в падение в 3 часа ночи и экстренный дебаг с плохим сном и самобичеванием в подарок.
? — это удобно. Но это — не обработка ошибки, а проброс её наверх. Это сигнал — «пусть эту ошибку разруливает тот, кто вызвал эту функцию». И она идёт выше и выше, пока не доползает до main(). А там она превращается в панику.
Решение: ОБРАБОТКА ошибок, а не их игнорирование.
// Правильная обработка ошибок в Rust let toast = old_toaster().map_err(|e| { eprintln!("Тревога: {}!!! Вызывай пожарных, Валера!", e); call_firefighters(); std::process::exit(255); // Эвакуируемся! }); let toast = match old_toaster() { Ok(t) => t, // Ням-ням! Err(e) => { eprintln!("Тревога: {}!!! Вызывай пожарных, Валера!", e); call_firefighters(); std::process::exit(255); // Эвакуируемся! }, }
Да, это дольше. Да, это больше строк. Да, надо думать. Но это честно: вы знаете, что будет, если тостер сломается. Вы знаете, что будет, если прод упадёт. Или не упадёт, если ошибка обработана. Можете спать спокойно.
Конечно, есть и другие способы: map_err, context, anyhow, thiserror. Но о них вы, я надеюсь, знаете. Этот проект — не учебник по Rust. Это спасение от нечитаемого императивного кода. Если вы хотите научиться писать на Rust функционально — вы по адресу. Если вы ещё не знакомы с языком — вы знаете, что делать: идти и читать The Book, и лишь потом возвращаться. Здесь вас научат писать правильно ;)
В обработке ошибок в Rust функциональный подход — это единственный правильный подход. Не игнорируйте ошибки. Не ленитесь их обрабатывать. Никогда. Сначала покажется, что это усложняет код. Но это не «усложнение». Это честность. А цена нечестности — ваша бессонная ночь, гневный звонок от заказчика и чувство собственной глупости, когда вы смотрите на unwrap() в логе и думаете про себя: «Я же знал. Я же знал, что здесь может быть ошибка». Как громко вы будете материться в 3 часа ночи? Вопрос риторический. Достаточно громко, чтобы разбудить соседей. Не доводите до этого. Обрабатывайте ошибки.
Следующие проблемы — это уже не про Rust (там они решены на уровне языка). Это про другие императивные и объектно-ориентированные языки, которые до сих пор мучают разработчиков по всему миру, и которые исправило ФП.
Проблема IV: Неявная пустота.
Представьте, что вы купили айфон. Счастья полные штаны, телефон куплен, но открывая коробку, вы обнаруживаете там пустоту. Вы хотите выплеснуть весь гнев на барыгу, который продал вам пустую красивую коробку. Вы перестаёте верить в людей. Вы перестаёте доверять коробкам.
// JavaScript let x = iPhoneBox(); // нет уверенности, что эта функция не вернёт null console.log(x); // null? Айфон? Кто знает... // А если повезёт — там айфон. А если нет — undefined или null. // И вы узнаете об этом только когда попытаетесь позвонить маме.
В JavaScript, Java, Python, C# и других императивных языках любое значение может быть null. Вы не знаете, где он спрячется. Вы не знаете, когда он вылезет. Вы только надеетесь, что «здесь точно не null». Но надежда — плохая стратегия. Тони Хоар, создатель null, назвал своё творение «ошибкой на миллиард долларов», но он не может от неё отказаться — слишком много кода написано с использованием null.
Решение — явная пустота.
ФП по умолчанию решает эту проблему: значение может быть пустым только явно — с помощью Option<T>:
// Rust let x: IPhone = IPhone::new(); // Эта функция ТОЧНО вернёт структуру IPhone x.call(mom); // Вы точно не позвоните по воображаемому телефону let maybe_none: Vec<Candy> = match get_candies() { // Паттерн-матчинг Some(c) => c, // Собираем конфеты в коробку None => Vec::new(), // Просто создаём новую коробку };
Option<T> — сигнал: этого значения может не быть. Если возвращаемый тип функции не обёрнут в Option — вы не можете получить пустоту, даже если очень захотите.
Проблема V: Классы.
В 80-х и 90-х классы казались революцией. Наконец-то можно было собрать данные и методы в одном месте! Это было свежо. Это было модно. Это обещало переиспользование, иерархии, полиморфизм. На практике же получился культ, который породил больше проблем, чем решил.
Проблема классов № 1: захламление кода.
Представьте себе коробку со старой техникой. Там есть всё — ваша «Денди», старые айфоны, может быть даже ваш спиннер из 2017 там затерялся, и вот это вот всё несут в себе классы.
В C++ эта проблема стоит наиболее остро, поскольку логика в классах делится на два файла: заголовок и сам код.
// myClass.h class MyClass { public: int myCoolVar; MyClass(int number); // Конструктор ~MyClass(); // Деструктор void endMyCruelLifePlease(); // Метод }
В данном файле есть только прототипы. Они все объявлены, но в них нет логики. Мы просто объявили, что конструктор, деструктор и метод существуют.
// myClass.cpp — здесь начинается ад #include "myClass.h" MyClass::MyClass(int number) { // ПОЖАЛУЙСТА myCoolVar = 10; } MyClass::~MyClass() { // ХВАТИТ // ... } MyClass::endMyCruelLifePlease() { // ОСТАНОВИТЕСЬ ++myCoolVar; }
Код на C++ физически и психологически невыносимо больно читать. И это один класс. А в реальном проекте их сотни. Тысячи. И каждый раз вы прыгаете между .h и .cpp, пытаясь понять, что же здесь происходит. Вы не программист — вы археолог, а иногда даже сапёр.
C++ не может отказаться от старой архитектуры, так как ему надо быть совместимым с C. C++ — помойка, в которой за 40 лет накопилось много всего, а начиная с C++20/23 там появилось и функциональное программирование, но оно выглядит ужасно грязно:
// C++20: функциональщина через переборы (выглядит как издевательство) #include <ranges> #include <vector> #include <iostream> int main() { std::vector<int> nums = {1, 2, 3, 4, 5}; auto result = nums | std::views::filter([](int x) { return x % 2 == 0; }) | std::views::transform([](int x) { return x * x; }); for (int x : result) { // И снова цикл. Нельзя просто взять и собрать. std::cout << x << " "; } // А в Rust мы бы просто написали: // let squares: Vec<i32> = (0..10).map(|x| x * x).collect(); // И никакого цикла. }
Решение: Разделить данные и логику, объединить всё в один файл.
В современных языках всё в одном файле, и у ФП нет классов: логика — отдельно, данные — отдельно.
// Rust: всё в одном месте. Никаких .h/.cpp. struct MyStruct { my_cool_var: i32, // Данные — в АТД! } impl MyStruct { // Логика — отдельно! fn new() -> Self { Self { my_cool_var: 10 } } fn my_method(&self, to_add: i32) -> i32 { self.my_cool_var + to_add } }
Код читается. Нет заголовочных файлов. Не нужно прыгать между файлами, не нужно гадать, где что объявлено.
Проблема классов № 2: Иерархия и наследование
В ООП класс можно унаследовать от другого класса:
// C++ class Animal { public: void speak(); } class Dog : Animal { // наследуем от Animal void speak() { // переопределяем std::cout << "Гав!" << std::endl; } } class Bird : Animal { // наследуем тоже от Animal void speak() { // переопределяем std::cout << "Чирик!" << std::endl; } } class DogBird : Dog, Bird { DogBird() { speak(); // вот тут-то и начинается веселье // Какой speak? Dog::speak? Bird::speak? // G++/MSVC/CLang++: Я не знаю, ты уж сам решай } }
Методы переплетаются. Мы наследуем два класса с одним и тем же методом. Начинается неопределённое поведение. Чей метод вызовется? В Python эта проблема решена частично, но это как синяя изолента на трещине на стене:
# Python class DogBird(Dog, Bird): def __init__(self): super().__init__() self.speak() # Кто первее, тот и прав! Вызовется метод от Dog. # Но если нам нужен от Bird, мы явно должны вызывать метод от Bird
Данная проблема называется «проблемой ромба», или «ромбовидное наследование», или «ромб смерти». Ромбом его назвали из-за формы иерархии:
A / \ B C \ / D
И это — не баг. Это — архитектура, обещавшая переиспользование, но в итоге заставившая программистов:
Трясти дерево наследования, в надежде, что несчастному программисту не упадёт яблоко на голову, как на Ньютона.
Аккуратно менять родительские классы, чтобы не поломать дочерние.
Страдать от «ромба смерти».
Решение: Избавиться от наследования, заменив его на композицию и трейты
В ФП нет классов и наследования. Вместо них есть:
// Композиция типов в АТД! struct Park { dog: Animal, bird: Animal, } // Трейты и их реализация! trait Animal { fn speak(&self); } impl Animal for Dog { fn speak(&self) { println!("Гав!"); } } impl Animal for Bird { fn speak(&self) { println!("Чирик!"); } }
Проблема классов № 3: Инкапсуляция, а точнее её смысл.
В ООП можно любую переменную сделать приватной и подключить к ней геттер/сеттер:
// C++ class MyClass { private: int myCoolVar; public: int getVar() { // Геттер return myCoolVar; } void setVar(int num) { // Сеттер myCoolVar = num; } }
Но у меня складывается библейско-философский вопрос: а зачем вообще её делать тогда приватной, если любой может её изменить? Это всё равно, что повесить на комнату табличку «не входить» — но оставить дверь открытой и повесить рядом ключ с надписью «если хочется, можете войти». Инкапсуляция? Какая ж это инкапсуляция, это бюрократия!
Решение: Не прописывать геттеры/сеттеры, делать значения публичными, и иммутабельно работать с ними.
// Rust struct MyStruct { pub my_cool_val: i32, } fn main() { let my_struct = MyStruct { my_cool_val: 10, }; let processed = my_struct.my_cool_val + 10; println!("{}", processed); }
Такой подход даже не требует реализации методов. Код короче и читаемее. Если нужно защитить значение — не обязательно делать его приватным: можно сделать его иммутабельным. Иммутабельность — настоящая инкапсуляция.
Проблема классов № 4: Слабый полиморфизм.
Полиморфизм — то, за что некоторые любят ООП. Но в ФП полиморфизм тоже есть! И он даже круче, чем в ООП.
В ООП вы создаёте хрупкую структуру с наследованием, виртуальными таблицами с оверхедом, и чтобы расширить полиморфизмы, вам придётся менять всю иерархию.
// ООП-полиморфизм в C++ class Shape { public: virtual double area() = 0; }; class Circle : public Shape { double r; public: Circle(double radius) : r(radius) {} double area() override { return 3.14 * r * r; } }; class Square : public Shape { double s; public: Square(double side) : s(side) {} double area() override { return s * s; } }; double totalArea(vector<Shape*>& shapes) { double sum = 0; for (auto s : shapes) sum += s->area(); return sum; } // 26 строк.
Решение: Функциональный полиморфизм с более простыми правилами.
В ФП вместо жёсткой иерархии предлагается свободная горизонтальная иерархия с трейтами, а также есть дженерики — можно реализовать один и тот же метод, который может принять любой тип, реализующий нужный трейт.
Также, чтобы изменить полиморфизм, вы не меняете всю иерархию в страхе что-либо сломать. Вы меняете трейт, либо делаете новый.
// Функциональный полиморфизм в Rust trait Shape { fn area(&self) -> f64; } struct Circle { r: f64 } impl Shape for Circle { fn area(&self) -> f64 { 3.14 * self.r * self.r } } struct Square { s: f64 } impl Shape for Square { fn area(&self) -> f64 { self.s * self.s } } fn total_area<T: Shape>(shapes: &[T]) -> f64 { shapes.iter().map(|s| s.area()).sum() } // 17 строк. Чистый код. Дженерики. // На 9 строк меньше. Без наследования. // Без виртуальных таблиц. Без риска сломать иерархию.
Кстати, реализовать функциональный полиморфизм можно даже для примитивных типов!
// Полиморфизм для примитивных типов в Rust trait Speak { fn speak(&self); } // Полиморфная функция (дженерик) fn make_sound<T: Speak>(obj: T) { obj.speak(); } impl Speak for i32 { fn speak(&self) { println!("{} говорит: я — число!", self); } } make_sound(67); // 67 говорит: я — число!
То же самое, кстати, и с другими языками функционального программирования. Не один Rust так крут:
-- Полиморфизм в Haskell class Speak a where -- это не ООП-класс, это вроде трейта в Rust speak :: a -> String instance Speak Dog where speak _ = "Гав!" instance Speak Cat where speak _ = "Мяу!" makeSound :: Speak a => a -> IO () makeSound animal = putStrLn (speak animal)
Такой полиморфизм не нагружает систему в рантайме: вместо этого он компилируется в мономорфные структуры, и за счёт этого полиморфизм не стоит вам производительности в функциональном программировании!
Проблема VI: Типизация.
В 90-х программисты устали от типов. В C++ приходилось писать std::vector<int>::iterator и молиться, чтобы компилятор понял. И появились языки, которые обещали свободу: Python, Ruby, JavaScript, PHP. Динамическая типизация казалась освобождением. Не надо думать о типах. Пиши — и работает.
И это сработало. Прототипы стали пилиться за часы, а не за дни. Казалось, что статическая типизация умерла. Что динамика победила. Что будущее — за языками, где «всё работает, пока не сломалось».
Но свобода без ответственности быстро превратилась в хаос и безнаказанность.
// JavaScript let user_id = 67 console.log("Hello, user #" + user_id) // Вау, как круто! // Число само преобразуется в строку! Не надо думать!
Интерпретатор превратился в терпилу, которого можно бить, и он вам ничего не сделает:
// JavaScript let x = Number("ПРИВЕТ"); console.log(x); // NaN
Он просто схавает то, что вы ему дали, и выдаст, что может. Он не скажет «вы ошиблись», «передана строка, ожидалось число». Он просто выдаст NaN (Not a Number) и пойдёт дальше. А потом гадай, почему в базе данных появилось NaN, и почему все расчёты пошли вразнос, и почему прод упал в три часа ночи.
// JavaScript console.log("2" + 2); // Это конкатенация console.log("2" - 2); // Это вычитание // Угадайте, к какому типу что будет приведено.
Один оператор конкатенирует строки, потому что он перегруженный — он может и конкатенировать строки, и складывать числа. Другой — только вычитает числа. Как JavaScript решает, что делать? Он гадает. У него есть правила, но они настолько запутанные, что даже опытные разработчики не всегда помнят, что будет в [] + [](пустая строка) или {} + [] (0). Это не программирование. Это угадайка. Если вы не можете быть уверены в значении — ваш код ненадёжен.
С динамической типизацией и неявными преобразованиями программирование стало проще. Но дебаг — сложнее. Скорость — иллюзия. Без управления типами вы больше потратите времени на дебаг, чем на программирование.
# Python def get_user_name(user): # А кто такой user? Словарь? Класс? return user.name # А какой тип? Есть ли вообще поле name у user?
В Python это называется «утиная типизация»: если это ходит как утка и крякает как утка — это утка. Но что, если это не утка? Что, если у вас в продакшн прилетел гусь без поля name? Python скажет AttributeError. Когда? В самый неподходящий момент. Например, в пятницу вечером в продакшне.
Причём есть языки как с динамической типизацией, как вышеперечисленные, так и со слабой, вроде C:
// C int x = 67; printf("%s\n", x); // 'C', потому что 67-й символ в ASCII — 'C'. // Компилятор может предупредить. А может и нет. // В любом случае, он скомпилирует. // А в продакшене вместо числа выведется символ. // Или краш. Или всё вместе.
C — это язык, где типы — это рекомендация. Компилятор может предупредить, но скомпилирует. А в продакшене вместо числа выведется символ. Или краш. Или всё вместе.
Решение: Строгие типы.
Строгие типы в ФП заставляют потратить чуть больше времени на программирование, чтобы не тратить всю ночь на дебаг.
// Rust: строгая типизация fn get_user_name(user: User) -> Result<String, Error> { // Тип говорит всё. user — это User. // Сигнатура функции — контракт. // Вернёт либо String, либо ошибку. Ok(user.name) } // let x = "2" + 2; // Так нельзя. let x = format!("2{}", 2); // А вот так можно. Сразу понятно, что нужно.
Ни один тип не преобразуется в другой неявно. Вы не можете сложить строку с числом. Вам нужны format!, to_string, parse, либо другие функции для преобразования типа. И так не только в Rust — это суть всего функционального программирования. Если вам нужен другой тип — преобразуйте его явно:
-- Haskell main :: IO () main = putStrLn $ show 67 -- Явно преобразуем Int в String c помощью show
Результат говорит сам за себя: ФП выбирают там, где ошибки — это не «ой, упало», а катастрофа. Финансы. Медицина. Авиация. Космос. Потому что у языков функционального программирования самый высокий аптайм среди всех. Они ближе всего к 100%. И это не потому, что разработчики — гении. Это потому, что ФП по своей природе не даёт делать глупые ошибки: типы проверяются, пустоты нет, мутация — под контролем. Компилятор — не враг. Он — страхующий трос.
Проблема VII: Исключения.
Раньше, когда исключений ещё не было, программа могла повести себя неправильно, натворить дел, или упасть с ошибкой сегментации за доступ к чужой памяти:
// C int* ptr = 67; *ptr = 256; // И камнем вниз...
Это было больно. Программа просто умирала. Без возможности отследить, где именно.
После, появились исключения. Идея казалась гениальной. Программа не умирала: она бросала исключения. Можно его поймать, обработать и даже продолжить работу.
// C++ try { riskyOp(); } catch (const std::exception& e) { std::cerr << "Ошибка: " << e.what() << std::endl; // Ошибка обработана, продолжаем работу. // Вроде бы продолжаем. Но что, если riskyOp() // бросила не std::exception, а int? // Или const char*? Или вообще ничего не бросила, а просто упала? // Вы не знаете. Вы надеетесь. }
Но проблема исключений заключается в том, что они нечестные.
Проблема исключений № 1: Вы не знаете, что именно может упасть.
// C++ void readFile(const std::string& path) { std::ifstream file(path); if (!file.is_open()) { throw std::runtime_error("Не удалось открыть файл"); } // ... }
Вы читаете код. Вы видите throw. Вроде бы, всё под контролем: вы знаете, какая функция бросает исключение. А что насчёт std::ifstream? std::string? Оператора <<? А конструкторы? А деструкторы?
Вы ничего не знаете. В C++ любая функция бросит исключение, если она не помечена явно как noexcept, и никто вас не предупредит: ни компилятор, ни ваш сосед, НИКТО. Если что-то пошло не так — читайте документацию. Или молитесь.
В Java ровно то же самое. ООП построено на исключениях.
// Java void riskyOp() throws IOException { // ... }
Здесь хотя бы честно: функция может выкинуть IOException, может вылезти ошибка ввода-вывода. Но что насчёт всеми любимого NPE? Его можно не объявлять. Он вас будет преследовать везде и всегда — как налоги, как капитализм, как судьба, ну или как озабоченный сталкер. Это — неубиваемая тварь, поскольку абсолютно всё в Java может быть null. И вы не знаете, когда он вылезет: сейчас, или когда он пришлёт вам привет в 3 часа ночи из прода.
Проблема исключений №2: А за собой убирать кто будет?
Вы открыли файл, аллоцировали память, взяли блокировку, и поймали маслину исключение.
// C++ void dangerous() { int* ptr = new int[1000]; std::ifstream file("data.txt"); // ... какая-то операция, которая бросает исключение delete[] ptr; // Сюда никогда не дойдём // file закроется в деструкторе? Повезёт, если да. }
В C++ есть RAII (Resource Acquisition Is Initialization), который помогает: деструкторы вызываются при раскрутке стека (выходе из области видимости). Но:
Это работает только для умных указателей, не для сырых.
Это работает, если вы правильно и грамотно написали деструкторы.
Это работает, если вы не забыли закрыть ресурс в деструкторе.
В Java, Python, JS, C# — сборщик мусора. Он соберёт, когда-нибудь. Может быть, через минуту. Может быть, через час. Может быть, когда файловых дескрипторов не останется.
Исключения не гарантируют, что ресурсы будут освобождены: это оставляют на вашу совесть.
В функциональных языках, вроде Haskell, Scala и F# сборщик мусора, кстати, тоже есть, и поэтому я и веду этот блог; поэтому я выбрал именно Rust и именно ФП: функциональщина — не враг скорости.
Проблема исключений №3: Исключения — это goto на стероидах.
Помните goto? Это он сейчас:
// C++ void a() { b(); } void b() { c(); } void c() { d(); } void d() { throw std::runtime_error("Опаньки..."); } int main() { try { a(); } catch (const std::exception& e) { // Поймали. А кто бросил? Где? Почему? // Нужно смотреть стектрейс. } }
Это как goto, только с неявными переходами. Вы не видите поток управления. Вы не знаете, где исключение будет обработано. Вы только знаете, что оно может быть. Или не быть. Или быть где-то ещё.
Проблема исключений №4: Исключения — это дорого.
Исключения в C++ и Java не бесплатны. Они:
Замедляют код, даже если исключение не брошено (нужно готовить таблицы для раскрутки стека).
Требуют выделения памяти для создания объекта исключения.
Требуют обхода стека в поисках обработчика.
Совершенно не подходят для высоконагруженных систем (привет, MISRA!).
Решение: Избавиться от исключений. Принять Option и Result.
В ФП нет исключений. Вообще. Вместо этого в Rust есть два стула:
Option<T>— когда значения может не быть.Result<T, E>— когда можете получить либо значение, либо ошибку.
Кстати, если среди читателей есть функциональщики на опыте в других языках, я сам заметил, что эти две штуки — на самом деле монады ;)
// Rust fn get_user(id: u64) -> Result<User, Error> { // Может вернуть Ok(user) или Err(error) } fn main() { match get_user(42) { Ok(user) => println!("Юзер: {}", user.name), Err(e) => eprintln!("Ошибка: {}", e), } }
// Rust fn process() -> Result<(), Error> { let user = get_user(42)?; // Если ошибка — выходим let data = user.load_data()?; data.save()?; Ok(()) }
Что это нам даёт?
Честность. Возвращаемый тип функции говорит сам за себя.
Result<User, Error>— значит, может быть успех или ошибка.Option<User>— значит, может быть пользователь или пустота.User— значит, пользователь будет точно. Не может бытьnull. Не может быть исключения. Не может быть неожиданности. Всё предсказуемо и понятно.Композиция. Оператор
?из примера выше — это как «попробуй выполнить, если ошибка — выйди». Чисто. Явно. Без скрытых переходов и побочных эффектов.Безопасность ресурсов. RAII в Rust работает всегда. Когда переменная выходит из области видимости, её ресурсы освобождаются. Исключений нет. Нет «а вдруг не вызовется деструктор». Всё предсказуемо и явно.
Производительность. В некоторых бенчмарках Rust обгоняет C++ по скорости, так как
OptionиResult— это самые обычные типы, а если быть точнее — АТД (мы до этого ещё дойдём). Нет скрытых таблиц. Нет обхода стека. Нет выделения памяти. Всё на стеке, всё быстро.
Всё это — заслуга не только Rust, но и ФП в целом. Это кстати ещё одна причина высокого аптайма функциональных языков.
Мы разобрали, чем плох старый, императивный ООП-подход. Но что особенного может дать ФП, кроме решения вышеперечисленных проблем? Три буквы:
АТД.
Или же Алгебраические Типы Данных.
АТД — это не просто типы. Это алгебра, где данные подчиняются законам.
Вы не можете создать невалидное состояние. Не можете забыть обработать вариант. Не можете перепутать поля. Компилятор проверяет всё, что можно проверить. А то, что нельзя — вы просто не можете выразить на языке типов.
В этом и заключается мощь и крутизна функционального программирования: не в монадах, не в чистых функциях, а в том, что типы не врут.
Почему Rust — один из особенных языков с АТД? Почему этого не могут предложить другие языки? Вот вам примеры ниже.
В Python и JS нет АТД вообще:
# Python # enum? struct? Что это? @dataclass # эмуляция struct... class Square: side: int
// JavaScript // interface? enum? Их добавят только в TypeScript.
В C АТД либо слабые, либо синтаксический сахар над int:
// C typedef struct { double x, double y } Point; // ну что это такое? Это разве АТД? // Просто слабенькая группа данных. typedef enum { IDLE, ONLINE, IN_GAME, OFFLINE } Player; // это вообще синтаксический сахар над int! ONLINE == 1...
В TS они просто слабые:
// TypeScript interface ButtonProps { type: "default" | "secondary" | "disabled"; onClick: () => void; } // Ну это хоть как-то можно притянуть на АТД... enum User { Online, Offline, Idle } // А это вообще не АТД. Это просто enum. // В конечном итоге, это просто надстройки над JS. В рантайме они исчезнут.
Функциональное программирование подарило миру АТД, в том числе и Rust.
Это — суммируемый тип, также известен как «ИЛИ». Значение может быть одним из вариантов.
// Rust enum Shape { Circle { radius: f64 }, Rectangle { width: f64, height: f64 }, Triangle { base: f64, height: f64 }, } // С композицией типов enum Option<T> { Some(T), None, } // Да, Option<T> — не только монада, но и АТД!
А вот это — производимый тип, также известен как «И». Значение содержит все поля одновременно.
// Rust struct Point { x: f64, y: f64, }
Композиция — это когда вы строите сложные типы из простых. Тоже особая фишка АТД.
// Rust struct Party { friends: Vec<Friend>, food: Vec<Pizza>, music: Music::Dubstep, }
Почему это круто?
Типы отражают свою предметную область. Если в вашей программе есть
Shape, который может бытьCircle,RectangleилиTriangle, то типShapeговорит об этом. Не документация. Не комментарии. Тип.Компилятор проверит полноту. Когда вы пишете
matchпоShape, компилятор проверит, что вы обработаливсеварианты. ЗабылиTriangle? Не скомпилируется. Это не «документация». Это контракт.Нет «недопустимых состояний». В ООП можно создать объект в некорректном состоянии. В ФП — нельзя. Тип не позволит.
enum User { Anonymous, Registered { id: u64, name: String }, } // Нет состояния, когда пользователь зарегистрирован, но без id. // Нет состояния, когда пользователь анонимный, но с id. // Тип не врёт.
Композиция вместо наследования даёт контроль вместо хрупких иерархий. Ваша структура не является чем-то — она содержит что-то. Ваша коробка с айфоном не является потомком коробки — она содержит в себе айфон.
struct MyBox<T> { content: Option<T>, } struct IPhone { os: IOS, charge: i8, on: bool, apps: Vec<App>, } enum App { Slack, Minecraft, Discord, Telegram, Element, YouTube, } fn main() { let phone = IPhone { os: IOS, charge: 100, on: true, apps: vec![Slack, Minecraft, Telegram, Element, YouTube], }; let my_box = MyBox { content: Some(phone) }; }
Что дают нам АТД в ФП и в Rust?
Option<T>— это суммаSome(T)илиNone. Нетnull. Нет NPE. Только честное «может быть, а может и нет». А ещё это монада, но это для тех, кто шарит ;)Result<T, E>— это суммаOk(T)илиErr(E). Нет исключений. Нет «да ладно тебе, что может пойти не так?». Тип говорит: «Здесь могла затаиться ошибка. Пристегни ремни и готовься к жёсткой посадке в случае чего».Компилятор как соавтор: вы не можете забыть обработать вариант в суммируемых типах. Не можете создать некорректное состояние. Не можете обратиться к полю, которого нет.
// Rust: паттерн-матчинг суммируемого типа match user { User::Anonymous => println!("Кто ты?"), User::Registered { id, name } => println!("Привет, {}!", name), }
Почему это важно для ФП?
Функциональное программирование — не «задротская темка», не только монады и функторы. Это — инструмент жёсткого контроля происходящего в коде. Rust даёт больший контроль по умолчанию, но в функциональном стиле — максимум контроля. Это про типы, которые не врут. Про данные, которые нельзя изуродовать и покалечить. Про компилятор, который двести тысяч раз проверит вашу логику, и скажет «да, это не упадёт в проде».
Где ФП вам не пригодится?
Кратко, без нытья и погружения в нишевые глубины: ФП — не для системного программирования. Не забывайте, компьютер — тупой, но послушный и очень быстрый раб. Все эти map, filter, fold, Option, Result — они живут в стандартной библиотеке. А в низкоуровневом коде этой библиотеки нет.
Rust без std — это другой Rust. Там нет Vec<T>. Нет Box. Нет итераторов. Там голые массивы, сырые указатели, ручное управление памятью и надежда, что вы не ошибётесь на байт.
На чипе с 2 КБ RAM и 8 КБ флеш-памяти вы не будете писать numbers.iter().map(|x| x * 2).collect(). Вы будете писать циклы. Вы будете мутировать переменные. Вы будете считать каждый байт и каждый такт.
TL;DR: Функциональное программирование — высокоуровневое. Это аксиома. Я мог бы не расписывать. Но сказал, чтобы вы не пытались забивать гвозди микроскопом.
Итог
Функциональное программирование не ново. Оно ждало своего часа десятилетиями. И только сейчас, когда железо перестало быть узким местом, а компиляторы научились оптимизировать абстракции, программисты начали понимать: самый надёжный код — это код, написанный в функциональном стиле.
Такой код не падает. Такой код читается. Такой код можно рефакторить без страха. Такой код позволяет спать ночью.
Почему Rust?
Rust — это функциональный язык в системной обёртке. Он взял от ФП всё самое важное: АТД, паттерн-матчинг, монады в виде Option и Result, функциональные итераторы. Но добавил то, чего ФП всегда не хватало: производительность.
Благодаря отсутствию сборщика мусора, строгой системе владения, чётким временам жизни и абстракциям с нулевой стоимостью, Rust стал самым быстрым языком функционального программирования. F#, Haskell, Lisp, Scala — все они уступают Rust в скорости и потреблении ресурсов.
Раньше ФП ругали за медлительность. «Это не для серьёзных задач», «это только для прототипов». Теперь у нас есть Rust. И последнего недостатка функционального программирования больше не существует.
Функциональный Rust — это не просто «ещё один язык». Это мост между миром высокой абстракции и миром голого железа. Это ФП, которое работает. Быстро. Безопасно. Предсказуемо.
Теперь выбор за вами. Продолжать писать императивный код и гадать, почему прод упал в 3 часа ночи. Или открыть редактор и попробовать функциональный Rust. Без mut. Без циклов. Без unwrap(). С чистым кодом. И со спокойным сном.
