Programming Paradigms
Парадигма — это стиль программирования и набор принципов использования средств языка. Многие языки поддерживают несколько парадигм (мультипарадигмальные).
Основные парадигмы
Императивное программирование (Imperative Programming).
Похоже на то, что делают компьютеры, - это последовательность команд, которые выполняются последовательно. Фокус на том, как решать задачу. Почти все языки обладают императивной природой.
Год появления: ~1940-е (машинный код)
Пример на Python:
total = 0
for i in range(5):
total += i
print(total)
т.е. мы буквально говорим как выполнять код каждый шаг - вот переменная total, теперь пройдись в цикле от 0 до 5 и суммируй в total порядковый номер итерации там образом...
Проблема императивного кода для ООП
ООП строится на других принципах:
- Инкапсуляция: объект сам управляет своим состоянием.
- Сообщения: мы общаемся с объектами через вызовы методов, не влезаем внутрь.
- Полиморфизм: мы подменяем реализацию объекта, но внешний код от этого не ломается.
А что делает императивный код?
- Он разрушает инкапсуляцию: вместо того, чтобы сказать объекту "сделай X", мы берём данные и сами решаем, как менять.
- Он увеличивает связность (coupling): внешний код начинает зависеть от структуры данных, а не от интерфейса объекта.
- Он ломает декларативность: вместо "что", мы начинаем описывать "как". В итоге класс превращается в набор тупых полей (DTO), а логика оказывается в статических функциях/процедурах.
Процедурное программирование (Procedural Programming).
Фокус — последовательное выполнение команд.
Указывает компьютеру что делать шаг за шагом. Пример: язык Си.
Используются: процедуры, функции, циклы, условные операторы (if).
Процедурное программирование это подмножество императивного.
Для уменьшения дублирования используют процедуры (функции без возвращаемого значения) и функции (с возвратом результата). Практически все языки поддерживают процедурный стиль.
Год появления: ~1950-е
Пример на C:
#include <stdio.h>
void greet() {
printf("Hello, world!\n");
}
int main() {
// Сначала вызываем функцию greet
greet();
// Затем выполняем завершение программы
return 0;
}
Структурное программирование (Structured Programming).
Структурное программирование — это подстиль императивного программирования, возникший в 60–70-х годах как реакция на «спагетти-код» с goto и сложностью управления сложностью кода, появилась необходимость в абстрациях.
Его основные принципы:
- Три базовых управляющих конструкции:
- последовательность (инструкции выполняются по порядку),
- ветвление (
if/else,switch), - цикл (
while,for). (всё остальное можно выразить через них).
- Разбиение программы на функции/подпрограммы для повышения читаемости.
- Запрет на goto (или сильное ограничение его применения).
Пример языков: C, Go, C#.
Год появления: 1966 (Эдгар Дейкстра)
Избегает использования goto, использует последовательности, ветвления и циклы для упорядочивания кода.
Пример на C:
int factorial(int n) {
int result = 1;
for(int i = 1; i <= n; i++) {
result *= i;
}
return result;
}
Декларативное программирование (Declarative Programming).
Декларативное программирование ближе к естественному образу мышления, в котором у нас есть сущности и отношения между ними. Акцент на описании чего мы хотим достичь, а не как. Пример — язык запросов SQL, где описывается запрос, а не алгоритм его выполнения. Обычно декларативные языки минимизируют использование состояний и переменных. Функциональное программирование — частный случай декларативного.
Год появления: ~1970-е (SQL и логическое программирование)
Пример SQL:
SELECT name FROM users WHERE age > 18;
println!("{}", (1..11).fold(0, |a, b| a + b));
т.е. мы говорим что нам надо, с минимум деталей в отличии от императивного подхода описания каждого шага.
// Императивный let mut sum = 0; for x in &arr { sum += x; } // Декларативный let sum: i32 = arr.iter().sum();
Пример Java функция интервал в императивном стиле:
public static int between(int 1, int r, int х) {
return Math.min(Math.max(l, х), r);
}
int у = Math.between(S, 9, 13);// сразу вычисляется
Пример Java функция интервал в декларативном стиле:
ciass Between implements Number {
private final Number num;
Between(Number left, Number right, Number х) {
this.num ~ new Min(new Max(left, х), right);
}
@Override
public int intValue() {
return this.num.intValue();
}
}
Number у = new Between(S, 9, 13); // еще не вычисляется!
Такой стиль будет декларативным, поскольку я не указываю процессору, что вычисления нужно выполнить сразу.
Я просто определил, что это такое, и оставил на усмотрение пользователя решение о том, когда (и нужно ли вообще) вычислять переменную у методом intValue(). Я еще не дал никакой работы процессору. Как указано в определении, выразил логику, не описывая процесс.
Смешивание стилей ведёт к “разрастанию” императивного кода: как только появляются мутации (значит, что мы будем менять состояние пошагово - накапливать, обновлять, мутировать коллекцию и т.п.) и шаги как сделать, код становится менее предсказуемым и тянет за собой похожие решения. В Rust это особенно видно: стоит начать с mut и циклов — и легко скатиться в "обычный C-подобный код"
Функциональное программирование (Functional Programming).
Функциональное программирование (ФП) — это подмножество декларативного подхода.
Акцент на построении программ из чистых функций и композиции функций (fnc().func().func()), избегающих побочных эффектов. В Rust аналог — цепочки итераторов.
В функциональном программировании функции должны быть чистыми — не изменять состояние, а лишь принимать аргументы и возвращать результат.
Языки: Haskell, Lisp, Erlang, Clojure, Scheme и др.
Чистые функции - результат таких функций зависит лишь от входных параметров. Не надо ломать голову «хм, а что будет, если перед вызовом этой функции я запущу вот эту».
- Нет мутаций данных: это означает, что объект данных не должен быть изменен после его создания.
- Неявное состояние: скрытого / неявного состояния следует избегать. В функциональном программировании состояние не исключается, вместо этого оно делается видимым и явным.
...
Это означает:
- Отсутствие побочных эффектов: функция или операция не должны изменять любое состояние за пределами своей функциональной области. Т.е. функция должна только возвращать значение вызывающему и не должна влиять на любое внешнее состояние. Это означает, что программы легче понять.
- Только чистые функции: функциональный код идемпотентен. Функция должна возвращать значения только на основе переданных аргументов и не должна влиять (побочный эффект) или зависеть от глобального состояния. Такие функции всегда дают один и тот же результат для одних и тех же аргументов.
Год появления: 1958 (с появлением Lisp)
В нулевых годах начали массово распространяться многоядерные и многопроцессорные системы. Возникла потребность в распределенных вычислениях, а чуть позже в вычислениях на графических процессорах. Оказалось, что ООП справляется с такими задачами значительно хуже, чем функциональные программы.
Пример на Rust (итераторы):
fn main() { let numbers = vec![1, 2, 3, 4]; let sum_of_squares: i32 = numbers.iter() .map(|x| x * x) .sum(); }
Функциональная парадигма - это подвид декларативной парадигмы программирования, которая строится на функциях, что удобно для параллельной и распределенной разработки. Программам, написанным с использованием данной парадигмы, свойственны такие свойства, как высокая степень параллелизации вычислений, повышенные требования к производительности и надежности. Не меняет внешнего состояния и не зависит от глобальных данных только от того что передали. Сразу готова к многопоточности и нет необходимости в блокировке данных. Все состояния хранится в стеке функции в виде аргументов.
- Неизменяемость данных
В функциональном программировании данные неизменяемы. Это означает, что при изменении данных создается новый объект, а старый остается неизменным. Это позволяет избежать ошибок, связанных с изменением данных во время выполнения программы.
- Чистые функции
Чистые функции - это функции, которые не имеют побочных эффектов и всегда возвращают одинаковый результат при одинаковых входных данных. Такие функции легко тестировать и сопровождать.
fn sum(a: usize, b: usize) -> usize { return a + b; }
- Композиция функций
Композиция функций - это процесс объединения нескольких функций в одну. Это позволяет создавать более сложные функции и повторно использовать код. Кроме того, композиция функций улучшает читаемость кода и позволяет избежать дублирования кода.
fn multiply_by_two(x: i32) -> i32 { x * 2 } fn add_three(x: i32) -> i32 { x + 3 } // Функция-композитор: принимает две функции и возвращает новую fn compose<F, G>(f: F, g: G) -> impl Fn(i32) -> i32 where F: Fn(i32) -> i32, G: Fn(i32) -> i32, { move |x| f(g(x)) } fn main() { // Создаём композицию: сначала multiply_by_two, потом add_three let multiply_then_add = compose(add_three, multiply_by_two); let value = 5; println!("Результат: {}", multiply_then_add(value)); // (5 * 2) + 3 = 13 }
- Функции высшего порядка
Функции высшего порядка - это функции, которые принимают другие функции в качестве аргументов или возвращают функции в качестве результата. Это позволяет создавать более абстрактный код и повышать его гибкость.
Ленивая оценка или нестрогая оценка - это процесс задержки оценки выражения до тех пор, пока он не понадобится. В общем, Rust проводит строгую / энергичную оценку. Мы можем использовать функции высшего порядка, замыкания и методы запоминания для выполнения ленивых вычислений.
fn add(x: usize) -> usize { // Это выводится, так как функции оцениваются в первую очередь println!("executing add"); return x + x; } fn multiply(x: usize) -> usize { // Это выводится, так как функции оцениваются в первую очередь println!("executing multiply"); return x * x; } fn add_or_multiply(add: bool, on_add: usize, on_multiply: usize) -> usize { if add { on_add } else { on_multiply } } fn main() { println!("{}", add_or_multiply(true, add(4), multiply(4))); // 8 println!("{}", add_or_multiply(false, add(4), multiply(4))); // 16 }
Это даст следующий вывод, и мы видим, что обе функции выполняются всегда
executing add
executing multiply
8
executing add
executing multiply
16
Мы можем использовать функции высшего порядка, чтобы переписать это в лениво оцененную версию
fn add(x: usize) -> usize { // это печатается, поскольку функции оцениваются первыми println!("executing add"); return x + x; } fn multiply(x: usize) -> usize { // это печатается, поскольку функции оцениваются первыми println!("executing multiply"); return x * x; } type FnType = fn(t: usize) -> usize; // Теперь это функция высшего порядка, поэтому оценка функций задерживается в if-else fn add_or_multiply(add: bool, on_add: FnType, on_multiply: FnType, t: usize) -> usize { if add { on_add(t) } else { on_multiply(t) } } fn main() { println!("{}", add_or_multiply(true, add, multiply, 4)); // 8 println!("{}", add_or_multiply(false, add, multiply, 4)); // 16 }
Это выводит то, что ниже, и мы можем видеть, что были выполнены только необходимые функции!
executing add
8
executing multiply
16
Вы также можете использовать методы запоминания / кэширования, чтобы избежать нежелательных оценок в чистых и ссылочных прозрачных функциях, как показано ниже
use std::collections::HashMap; fn main() { let mut cached_added = HashMap::new(); let mut add = |x: usize| -> usize { return match cached_added.get(&x) { Some(&val) => val, _ => { println!("{}", "executing add"); let out = x + x; cached_added.insert(x, out); out } }; }; let mut cached_multiplied = HashMap::new(); let mut multiply = |x: usize| -> usize { return match cached_multiplied.get(&x) { Some(&val) => val, _ => { println!("executing multiply"); let out = x * x; cached_multiplied.insert(x, out); out } }; }; fn add_or_multiply(add: bool, on_add: usize, on_multiply: usize) -> usize { if add { on_add } else { on_multiply } } println!("{}", add_or_multiply(true, add(4), multiply(4))); // 8 println!("{}", add_or_multiply(false, add(4), multiply(4))); // 16 }
- Рекурсия
Рекурсия - это процесс, при котором функция вызывает саму себя. В функциональном программировании рекурсия является основным способом повторения операций. Она позволяет создавать более элегантный и краткий код.
Функциональное программирование способствует рекурсии по циклу. Давайте посмотрим пример для вычисления факториала числа.
В традиционном итеративном подходе:
fn main() { fn factorial(mut num: usize) -> usize { let mut result = 1; while num > 0 { result *= num; num = num - 1; } return result; } println!("{}", factorial(20)); // 2432902008176640000 }
То же самое можно сделать с помощью рекурсии:
fn main() { fn factorial(num: usize) -> usize { return match num { 0 => 1, _ => num * factorial(num - 1), }; } println!("{}", factorial(20)); // 2432902008176640000 }
Недостатком рекурсивного подхода является то, что он будет медленнее по сравнению с итеративным подходом в большинстве случаев и может привести к ошибкам переполнения стека, поскольку каждый вызов функции должен быть сохранен как кадр в стек. Избегать этой хвостовой рекурсии предпочтительнее, особенно когда рекурсия выполняется слишком много раз.
В хвостовой рекурсии рекурсивный вызов - это последнее, что выполняется функцией, и, следовательно, кадр стека функций не должен сохраняться компилятором. Большинство компиляторов могут оптимизировать код хвостовой рекурсии таким же образом, как оптимизируется итеративный код, что позволяет избежать потери производительности. Но, к сожалению, Rust пока не поддерживает это.
Карринг (англ. currying) — это приём в функциональном программировании, при котором функция с несколькими аргументами преобразуется в последовательность функций, каждая из которых принимает только один аргумент и возвращает новую функцию, ожидающую следующий аргумент.
Зачем нужен карринг
-
Частичное применение функций — можно заранее зафиксировать часть аргументов, а остальные передавать позже.
-
Повышение переиспользуемости кода — одна универсальная функция превращается в набор более специализированных.
-
Чистота и читаемость — особенно полезно в цепочках функционального стиля.
fn add(x: i32) -> impl Fn(i32) -> i32 { move |y| x + y } let add5 = add(5); // функция, которая прибавляет 5 println!("{}", add5(3)); // 8
Объектно-ориентированное программирование (ООП).
Из-за сложности программ, появилась необходимость в абстрациях. Оперирует понятиями класса и обьекта, а также инкапсуляция (упаковка, делегировании ответственности), наследование (расширение), полиморфизм (повторное использование). Дает гибкость в структурировании программ. Это архитектура приложения, способ моделирования предметной области. При котором используется разделение на сущности по принципу - недопустить возможность ввести сущность в некорректное состояния.
Основные принципы:
- Инкапсуляция — упаковка данных и методов или герметичность данных, скрывающая внутреннее устройство объекта и предоставляющая безопасный интерфейс для его использования. Цель — защита инвариантов и предотвращение некорректных изменений состояния. Суть инкапсуляции - в делегировании ответственности объекту. Таким образом объект получает право управлять своими (и не только) данными удобным для себя способом.
- Наследование — механизм, позволяющий одному классу/типу расширять или переопределять функциональность другого. Цель — повторное использование кода и расширяемость.
- Полиморфизм — способность использовать единый интерфейс для разных реализаций. Цель — уменьшение дублирования и гибкая подмена поведения в зависимости от конкретного типа.
ООП помогает создавать гибкие, легко расширяемые приложения, следя за корректностью состояний объектов.
Год появления: 1960-е (Simula), активно популяризировано в 1980-х.
Пример на Python:
class Animal:
def speak(self):
pass
class Dog(Animal):
def speak(self):
return "Woof!"
dog = Dog()
print(dog.speak())
Почему стал популярен подход ООП.
Так как до ООП использовался процедурный/императивный подход, который не мог справиться с возрастающей сложностью требований к программам по таким причинам:
-
Управление сложностью (Managing Complexity): По мере роста программ, в процедурном подходе возникала проблема "спагетти-кода". Все данные были доступны глобально, и функции могли изменять их в любом месте, что затрудняло отслеживание изменений и предсказание поведения программы. ООП предложил способ модуляризации кода. Вместо набора функций, оперирующих глобальными данными, появились "объекты", которые инкапсулируют (объединяют) данные и функции, работающие с этими данными. Это делает части программы более независимыми и легче управляемыми.
-
Повторное использование кода (Code Reusability): В процедурном программировании, если вам нужна была похожая функциональность для разных типов данных, часто приходилось дублировать код. ООП ввел концепцию наследования. Вы можете создать базовый класс с общими характеристиками и поведением, а затем создать производные классы, которые наследуют эти характеристики и могут добавлять свои собственные или изменять унаследованные. Это значительно сокращает дублирование кода и упрощает его поддержку.
-
Инкапсуляция (Encapsulation): В процедурном программировании данные часто были доступны напрямую, что позволяло любой части программы изменять их. Это могло приводить к непредсказуемым ошибкам. ООП позволяет инкапсулировать данные внутри объекта, делая их доступными только через определенные методы (интерфейс объекта). Это называется упаковкой информации. Таким образом, внутреннее состояние объекта защищено от некорректных изменений извне, что повышает надежность и упрощает отладку.
-
Реальное моделирование (Modeling Real-World Entities): В реальном мире мы мыслим объектами: машина, человек, счет в банке. У каждого объекта есть свои характеристики (данные) и действия, которые он может выполнять (поведение). ООП позволяет напрямую моделировать эти реальные сущности в коде, делая программу более интуитивной и легкой для понимания. Объект "Машина" может иметь свойства "цвет", "скорость" и методы "завести", "остановить".
-
Полиморфизм (Polymorphism): ООП позволяет объектам разных классов реагировать по-разному на одно и то же сообщение (вызов метода). Например, у вас может быть базовый класс "Фигура" и производные классы "Круг" и "Квадрат". У всех них может быть метод "нарисовать()", но каждый будет реализовывать его по-своему. Это делает код более гибким и расширяемым.
Entity-Component System (ECS)
Критика Кейси Муратори ООП подхода Роберта Мартина в основном направлена на высокопроизводительные системы, где каждая наносекунда на счету (например, игровые движки, низкоуровневые симуляции). Для большинства бизнес-приложений, где производительность не является критическим фактором, преимущества ООП в плане управляемости сложности, модульности и повторного использования кода могут перевешивать его недостатки.
Год появления: 2000-е, популяризирована в разработке игр
Паттерн архитектуры, где объекты (Entities) состоят из компонентов (Components) с данными и систем (Systems), которые управляют поведением.
Основной недостаток ООП, который решается в ESR (Entity–State–React), — это чрезмерная сложность и негибкость, вызванная жёсткой привязкой поведения к данным (т.е. к объектам).
...
Конкретная проблема ООП, решаемая в ESR:
Жёсткая связь между состоянием и поведением
В классическом ООП поведение (методы) жёстко привязано к данным (полям класса), что приводит к:
- Раздутым классам – объекты берут на себя слишком много ответственностей.
- Сложности изменения поведения – если нужно изменить логику, приходится модифицировать классы или создавать сложные иерархии наследования/композиции.
- Проблемам с тестированием – из-за мутаций состояния и скрытых зависимостей.
Как ESR решает эту проблему?
ESR разделяет:
- Сущности (Entity) – просто контейнеры для данных (без логики).
- Состояние (State) – данные, связанные с сущностью.
- Реакции (React) – отдельные функции, обрабатывающие состояние.
Это даёт:
- Гибкость – поведение можно менять, не трогая структуру данных.
- Простота тестирования – реакции (чистые или детерминированные функции) легко тестируются.
- Меньше boilerplate-кода – не нужны сложные паттерны вроде Visitor или Strategy.
Пример сравнения:
❌ В ООП (проблема):
class User {
private String name;
private int balance;
// Жёстко привязанное поведение
public void addMoney(int amount) {
this.balance += amount;
}
}
Если нужно изменить логику начисления, придётся менять класс User.
✅ В ESR (решение):
// Данные
const user = { name: "Alice", balance: 100 };
// Отдельная функция-реакция
function addMoney(user, amount) {
return { ...user, balance: user.balance + amount };
}
Логика изменяется без модификации структуры user.
Когда ESR нужен:
- Для сложных предметных областей, где поведение зависит от многих факторов
- Когда нужно гибко комбинировать компоненты (как в ECS в gamedev)
- Когда поведение должно работать с множеством разных сущностей
Вывод:
ESR устраняет главный недостаток ООП — жёсткую связь данных и поведения, предлагая более декомпозированную и гибкую модель. Это особенно полезно в сложных или часто меняющихся системах.
Мультипарадигма
Rust — мультипарадигмальный язык программирования. Он сочетает императивный стиль с элементами функционального, процедурного подходов и объектной системы (через структуры, методы и трейты).
Функциональная парадигма - последовательный вызов ф-ции с передачей контекста, методы итераторов и векторов
fn main(){ let value:[i32;5] = [1,2,3,4,5]; let res:i32 = value.iter().filter(|&x| x % 2 == 0).map(|v|v*2).sum(); println!("Функциональная парадигма {}",res); }
Процедурная парадигма - вызовы ф-ций с сохранением состояния между вызовами, через присваивание.
Вариант с итераторами:
type Filter<'a> = core::iter::Filter<std::slice::IterMut<'a, i32>,for<'r> fn(&'r &mut i32) -> bool>; fn filter(iter:&mut [i32])->Filter{ iter.into_iter().filter(|x| **x % 2 == 0) } fn map(filter:Filter)->i32{ filter.map(|v|*v*2).sum() } fn main(){ let mut value:[i32;5] = [1,2,3,4,5]; let value_filter = filter(&mut value); let res:i32 = map(value_filter); println!("Процедурная парадигма {:?}",res); }
Вариант с классическими циклами:
fn filter(v:&mut Vec<i32>){ let mut indexes:Vec<usize>=vec![]; for (pos,value) in v.iter().enumerate(){ if *value%2!=0{ indexes.push(pos); } } indexes.sort(); let mut correct_pos = 0; for pos in indexes{ v.remove(pos-correct_pos); correct_pos+=1; } } fn map(filter:Vec<i32>)->i32{ let mut res = 0; for v in filter{ res+=v*2; } res } fn main(){ let mut value:Vec<i32> = vec![1,2,3,4,5]; filter(&mut value); let res:i32 = map(value); println!("Процедурная парадигма {:?}",res); }
Разница с классическим ООП: нет наследования состояния, но есть композиция и полиморфизм через трейты.
use oop::Value; mod oop{ pub struct Value{ value:Vec<i32>, pub sum:Option<i32> } impl Value{ pub fn new(value:Vec<i32>)->Self{ Self{value:value,sum:None} } pub fn calculate(&mut self){ self.sum = Some(self.value.iter().filter(|&x| x%2 ==0).map(|v|v*2).sum()) } } } fn main(){ let mut value:Value = Value::new(vec![1,2,3,4,5]); value.calculate(); println!("ООП парадигма {:?}",value.sum.unwrap()); }