
В этой статье попробуем разобраться, почему ООП — худшее, что было придумано в программировании, как оно стало таким популярным, почему опытные программисты Java (C#, C++ и т.п.) в принципе не могут считаться крутыми инженерами, а код на Java - хорошим.
К сожалению, программирование достаточно далеко от науки (как и я), поэтому многие термины могут быть интерпретированы по-разному, так что давайте сначала с ними определимся. Сразу предупреждаю, что эти определения — мое субъективное мнение, попытка навести порядок и заполнить пробелы. Конструктивная критика приветствуется.
Определения
-
Структура [данных] — чистые данные, не содержащие логики обработки (функций).
-
Функция — блок кода, выполняющий определенную логику. Может возвращать значение.
-
Процедура — блок кода, выполняющий определенную логику, не возвращающий значение. Предлагаю считать это понятие подмножеством функции и убрать из обихода, добавив определение процедурного стиля.
-
Объект — сущность, содержащая как данные, так и функции для их обработки — методы. Объект можно имитировать в ФП, если положить данные и функции в одну структуру. В классическом ООП это всегда экземпляр класса.
-
Класс — шаблон для создания объектов, определяющий их данные и методы. Основа ООП.
-
Метод — функция, являющаяся частью класса. Методы экземпляра (не статические) имеют неявную ссылку на сам объект (
this
,self
), со всеми его данными и методами, которая фактически является принудительным скрытым первым аргументом. -
Функциональное программирование (ФП) — это программирование используя структуры и функции. Не путать с функциональным стилем.
-
Объектно-ориентированное программирование (ООП) — это программирование используя классы, объекты и все их особенности — наследование, инкапсуляцию, полиморфизм и тп. При желании можно имитировать структуры классами [почти] без методов и функции статическими классами со статическими методами.
-
Изменяемый (мутабельный) стиль — стиль программирования, в котором при изменении данных их принято менять на месте, а не копировать. Может быть использован как в ФП, так и в ООП, но свойственен ООП.
-
Неизменяемый (иммутабельный) стиль — стиль программирования, в котором при изменении данных их принято НЕ менять на месте, а создавать новые копии. Может быть использован как в ФП, так и в ООП, но не свойственен ООП.
-
Математический (обычно — функциональный) стиль — стиль ФП, которому присущи неизменяемый стиль и чистые функции — функции, которые всегда возвращают один и тот же результат на одни и те же аргументы (по простому — не используют внешнее состояние), что свойственно математическим функциям (не путать с функциями из программирования).
Частые возражения
-
ООП изначально задумывалось как <что то другое>.
-
Я не вижу никакого смысла рассуждать про чьи-то давние фантазии, а исхожу из того, к чему в итоге пришли.
-
-
Функциональное и процедурное программирования — разные вещи.
-
Я постоянно встречаю употребление ФП, когда имеется ввиду ПП, а последнее вообще не встречаю. Само понятие процедура уже почти никем не используется, функция же наоборот — используется постоянно, скорее всего из-за ключевых слов в популярных языках, типа
function
илиfunc
. При этом довольно часто встречаю понятие функционального стиля, поэтому решил разделить понятия парадигм и стилей программирования. Также я стараюсь избегать лишних понятий для этой статьи, например, императивного и декларативного стиля.
-
Недостатки
Первым звоночком, заставившим задуматься об удачности концепции ООП стала травма детства задача с первого собеседования по ООП языку, который я на тот момент еще только начал учить — C#, и которая врезалась в память по сей день:
Задача: что выведется в консоль при запуске программы
class Base
{
static Base()
{
Console.WriteLine("Static Base");
}
public Base()
{
Console.WriteLine("Instance Base");
}
}
class Program : Base
{
public static readonly Program Instance;
static Program()
{
Console.WriteLine("Static Program");
Instance = new Program();
}
private Program()
{
Console.WriteLine("Instance Program");
}
static void Main()
{
Console.WriteLine("Main");
}
}
Первая мысль тогда — как и зачем человеческий разум смог придумать что то подобное — программа создает саму себя (что? о_О) из своего же метода, и далее начинается еще более черная магия — вызываются много других методов в последовательности, предсказать которую, не зная этой магии изнутри, практически невозможно.
Но и это были цветочки — далее могли следовать вопросы по паттерну синглтон и потокобезопасности инициализации экземпляра программы через статический конструктор, но тогда до них не дошли. Да и как выяснилось позже, даже этот код недостаточно ООП-шный — там нет ни одной фабрики и ни одного контейнера зависимостей.
Впрочем, тогда я воспринимал все с точки зрения "наверное, зачем то так надо", и компетенции отказаться от изучения C# еще не хватало. Завалив собеседование, я начал погружаться в дебри ООП, с каждым годом приближаясь к мысли, что все таки "нет, не надо".
Чтобы понять, насколько ООП хорошая идея, достаточно разобрать все моменты, которые отличают его от ФП, и сравнить их плюсы и минусы. Но по большому счету главное отличие одно — классы. Поэтому начнем разбор с особенностей классов.
Далее примеры ООП будут написаны либо на C#, либо на TypeScript. Примеры ФП будут на TypeScript. Все утверждения касаются классического подхода, а не всевозможных имитаций ФП в ООП и наоборот.
Методы
Речь пойдет про методы экземпляра, так как статические методы это по сути имитация ФП. Простой пример:
class User {
firstName: string
lastName?: string
middleName?: string
... // Другие поля, не нужные для getDisplayName.
constructor(firstName: string, lastName?: string, middleName?: string) {
this.firstName = firstName
this.lastName = lastName
this.middleName = middleName
}
// Метод.
getDisplayName() {
return [this.firstName, this.middleName, this.lastName]
.filter(Boolean)
.join(" ")
}
... // Другие методы, не нужные для getDisplayName.
}
// Функция.
const getDisplayName = (user: {firstName: string, lastName?: string, middleName?: string} | null | undefined) => {
if (!user) return undefined
return [user.firstName, user.middleName, user.lastName]
.filter(Boolean)
.join(" ")
}
// Еще более гибко, но может быть менее удобно.
const getDisplayName = (firstName: string, lastName?: string, middleName?: string) => {
...
}
Чем отличаются метод и функция getDisplayName
?
Во-первых, метод намертво приколочен к типу своего скрытого аргумента — this
, которым является User
. Он зависит не от интерфейса, а от конкретного класса. Отсюда мы имеем сразу несколько проблем:
-
Переиспользование с другими типами: метод требует для работы не только данные и методы, что ему нужны, но и те что не нужны, но есть в классе
User
- то есть абсолютно все поля и методы этого класса, включая приватные. Значит тот, кто переиспользует метод, тоже должен включать их в себя, и не важно наследованием (а это один сплошной недостаток - об этом позже) или делегированием. Получается что переиспользовать метод с другим типом, предоставляя только реально используемые этим методом данные и функции, невозможно. -
Необходимость дальнейшего использования классов: нельзя использовать метод без создания экземпляра этого класса или его потомка. Например, нельзя использовать для словаря с такими же полями.
-
Невозможность обработки ситуации, когда user
null
илиundefined
в самом методе.
В JS/TS можно конечно закостылять все это через call
/apply
, но это костыли конкретного языка, противоречащие KISS и сами по себе являются признаком плохого кода.
// ООП
class Dog extends Animal {
firstName: string
lastName?: string
// Как переиспользовать метод getDisplayName из класса User?
}
({firstName: "Alexander"}).getDisplayName() // Ошибка: у object нет такого метода.
let user: User | null
user.getDisplayName() // Ошибка: null reference.
// ФП
getDisplayName({firstName:"Alexander"}) // Alexander
getDisplayName(new User("Alexander", "Danilov")) // Alexander Danilov
const dog: Dog = {
firstName: "Шарик",
color: "black"
}
getDisplayName(dog) // Шарик
getDisplayName(undefined) // undefined
На лицо — сильные ограничения по переиспользованию, провоцирование багов и худших практик программирования.
В ФП у функций сигнатура минималистична, содержит только необходимые поля, а типы аргументов по сути являются интерфейсами, не требующими их явной реализации.
Второе отличие — переопределение методов. В некоторых языках существует несколько способов переопределить метод в дочернем классе, и в целом запретить переопределение. Тот кто это придумывал очевидно полагал, что способов выстрелить себе в ногу в ООП все еще недостаточно. Пример в C#:
// Нельзя переопределить в дочерних классах.
public void GetDisplayName()
// Можно переопределить.
public virtual void GetDisplayName()
// Переопределение метода в дочернем классе.
public override void GetDisplayName()
// Переопределение метода в дочернем классе, но в следующих
// потомках переопределить его (зачем то) нельзя.
public sealed override void GetDisplayName()
// Самое дикое — вызываемый метод зависит от типа ссылки, у которой он
// вызывается (facepalm). Если родительский — то вызовется родительский
// метод, если конкретного экземпляра с new — метод этого экземпляра.
public new void GetDisplayName()
Аналог переопределения метода в ФП:
const getUserDisplayName = (user: ...) => {...}
const getAdminDisplayName = (admin: ...) => {
if (...) {
// В определенных случаяx переиспользуем getUserDisplayName.
return getUserDisplayName(admin)
}
// Какая то уникальная для admin логика отображения имени.
return ...
}
Все настолько просто, насколько возможно.
Итог:
Получается, что методы проигрывают функциям во всем, кроме одной мелочи, связанной исключительно со средами разработки и сложившейся нотацией их вызова (обсудим в самом конце), накладывают серьезные ограничения по переиспользованию кода в других типах, а также провоцируют худшие практики программирования, добавляя еще больше возможностей "выстрелить в ногу" на пустом месте. Итого, методы — мусор. Идем дальше.
Наследование
По поводу данной особенности даже среди ООП-шников давно выработалось правило — наследование это антипаттерн, и нужно предпочитать делегирование.
Почему? Да потому что, во-первых, нельзя отнаследовать определенные поля или методы — только целиком весь класс. Эта проблема носит даже отдельное название — Проблема банана и макаки от Джо Армстронга: тебе нужен был банан, но ты получил макаку, держащую банан, со всеми джунглями в придачу.
Во-вторых — в большинстве языков наследоваться можно только от одного класса.
Пример:
// ООП
class User {
id: string
name: string
surname: string
address: string
friends: User[]
constructor(name: string, surname: string, address: string, friends: User[]) { … }
getDisplayName() { … }
hasFriend(id: string) { … }
}
// Плохо: наследование.
// У Npc не должно быть address, friends и hasFriend.
class Npc: User {
constructor(name: string, surname: string) {
super(name, surname, "", []) // Обязаны предоставить поля, которые нам не нужны.
}
}
// Плохо: изменить изначальный код и разбить на более мелкие классы.
class Nameable {
name: string
surname: string
getDisplayName() { … }
}
class Friendable {
friends: User[]
hasFriend(id: string) { … }
}
// Как сконструировать User если нет множественного наследования? Делегирование?
// От какого класса наследоваться, а какой вложить (делегировать)?
// Или не наследоваться вовсе и вложить оба?
// Что если данные классы используют приватные поля, к которым нет доступа?
// Кому то нравится данный код? (вопрос риторический)
class User {
nameable: Nameable
friendable: Friendable
}
Что мы имеем:
Чтобы переиспользовать что-либо из имеющегося класса, нужно либо брать все что в нем есть, либо переписывать существующий код и выделять код в другие классы, но даже в таком случае без множественного наследования нормально сконструировать классы не получится.
Множественное наследование несет в себе еще больше проблем, и от него отказались во многих популярных ООП языках.
// ФП
type BaseUser = {
id: string
name: string
surname: string
}
// Объединение вместо наследования.
type User = BaseUser & {
address: string
friendIds: string[]
}
// Алиас.
type Npc = BaseUser
// Вариант без BaseUser. Даже базовый тип не всегда нужен — можно взять поля из другого типа.
type Npc = Pick<User, "id" | "name" | "surname">
// Указываем в функции только то, что нужно, даже не BaseUser а только friendIds.
const hasFriend = (friendIds: string[], friendId: string) => { … }
// Или требуем тип с полем friendIds.
const hasFriend = (target: { friendIds: string[] }, friendId: string) => { … }
hasFriend(user, "123") // ОК
hasFriend(npc, "123") // Ошибка компиляции: npc имеет тип Npc, а в нем нет friendIds.
Как видим самый верный вариант — использовать не наследование, и даже не делегирование, а компоновку типов (в TypeScript - union type, типы Pick
, Omit
и т.п.). И если в структуре есть все поля, необходимые для вызова функции, то никаких ограничений в вызове этой функции нет.
Итог: наследование добавляет множество проблем, но не решает ни одной. Мусор даже по меркам ООП.
Полиморфизм
Полиморфизм — способность функции обрабатывать данные разных типов.
Классический полиморфизм в ООП реализуется через наследование в худшем случае (имеем все перечисленные ранее проблемы), и через интерфейс в лучшем — очередной выбор двух стульев ООП. В случае интерфейса код не будет зависеть от конкретной реализации, вот только нужно решать, где хранить реализацию методов по умолчанию. И конечно же в обоих случаях есть недостаток — необходимость использования классов (см. пред. пункты).
using System;
using System.Collections.Generic;
// Абстрактный класс.
abstract class Shape
{
public abstract double GetArea();
}
// И/или интерфейс.
interface IShape
{
double GetArea();
}
class Circle : Shape
{
public double Radius { get; }
public Circle(double radius)
{
Radius = radius;
}
public override double GetArea()
{
return Math.PI * Radius * Radius;
}
}
class Rectangle : Shape
{
public double Width { get; }
public double Height { get; }
public Rectangle(double width, double height)
{
Width = width;
Height = height;
}
public override double GetArea()
{
return Width * Height;
}
}
// Фабрика для создания фигур из сырых данных.
class ShapeFactory
{
public static Shape CreateShape(Dictionary<string, object> rawData)
{
if (!rawData.ContainsKey("type")) return null;
string type = rawData["type"].ToString() ?? "";
switch (type)
{
case "circle":
if (rawData.TryGetValue("radius", out var radiusObj) && radiusObj is double radius)
return new Circle(radius);
break;
case "rectangle":
if (rawData.TryGetValue("width", out var widthObj) && widthObj is double width &&
rawData.TryGetValue("height", out var heightObj) && heightObj is double height)
return new Rectangle(width, height);
break;
}
return null; // Неизвестный тип.
}
}
class Program
{
static void Main()
{
var rawShapes = new List<Dictionary<string, object>>
{
new Dictionary<string, object> { { "type", "circle" }, { "radius", 5.0 } },
new Dictionary<string, object> { { "type", "rectangle" }, { "width", 4.0 }, { "height", 6.0 } },
};
// Сначала нужно преобразовать сырые данные в экземпляры нужных классов, используя ShapeFactory.
var shapes = rawShapes.ConvertAll(ShapeFactory.CreateShape);
LogShapes(shapes);
}
static void LogShapes(List<Shape> shapes)
{
foreach (var shape in shapes)
{
Console.WriteLine($"Площадь: {shape.GetArea()}");
}
}
}
В ФП принято использовать параметрический (истинный) полиморфизм:
type Circle = { type: "circle"; radius: number }
type Rectangle = { type: "rectangle"; width: number; height: number }
type Shape = Circle | Rectangle
const getArea = (shape: Shape): number => {
// Убедитесь, что правило ESLint @typescript-eslint/switch-exhaustiveness-check, требующее исчерпывающие блоки switch, включено.
// Данный код полностью типизирован и проверяется компилятором.
switch (shape.type) {
case "circle":
return Math.PI * shape.radius * shape.radius
case "rectangle":
return shape.width * shape.height
}
}
// В коде одного проекта лучше использовать тип Shape и реализацию getArea, чтобы не усложнять код.
const logShapes = (shapes: Shape[]) => {
shapes.forEach(shape => console.log(`Площадь: ${getArea(shape)}`))
}
// Используем сырые данные, без лишних преобразований.
logShapes([
{ type: "circle", radius: 5 },
{ type: "rectangle", width: 4, height: 6 },
])
// В библиотеке функцию можно сделать более гибкой, использовав обобщения и аргумент getArea (можно опциональный,
// с реализацией по умолчанию), которой вообще все равно, какой тип предоставляется.
const logShapes = <T,>(shapes: T[], getArea: (shape: T) => number) => {
shapes.forEach(shape => console.log(`Площадь: ${getArea(shape)}`))
}
logShapes(
[
{ type: "circle", radius: 5 },
{ type: "rectangle", width: 4, height: 6 },
{ type: "triangle" }, // Ошибка компиляции: данный тип не поддерживается getArea.
],
getArea
)
// Пример из секции про наследование - тоже полиморфизм.
// Здесь мы принимаем любой тип, который содержит friendIds: string[].
const hasFriend = (target: { friendIds: string[] }, friendId: string) => { … }
Итог: Как мы видим, полиморфизм в ФП прекрасно реализуется без классов и всех их недостатков, код более простой и лаконичный даже на классических примерах из ООП — в реальных проектах все намного сложнее, и с ростом кода разница только растет.
Инкапсуляция
Здесь быстро пробегусь по аналогам private
, public
и т.п. у классов в TypeScript для ФП:
// Функция "публичная", так как экспортируется.
export const getDisplayName = () => …
// Не экспортируется — доступна только в файле.
const capitalize = () => …
// Храним приватные данные в замыкании.
const makeAccount = () => {
let balance = 0
return {
deposit: (amount: number) => {
if (amount < 0) { throw … }
balance += amount
},
…
}
}
// Cкрываем поля с помощью приватного типа.
const privateReducer = (state: PrivateState): PrivateState => {
// Внутри функции работаем с состоянием используя приватные поля.
}
// Экспортируем функцию с публичным типом.
export const reducer = privateReducer as (state: State) => State
// Делаем поля только для чтения с помощью приведения к типу для проверки во время компиляции.
const readonlyArray = ["Вася"] as const
readonlyArray[0] = "Петя" // Ошибка компиляции.
// С неизменяемым типом и проверкой как во время выполнения, так и во время компиляции.
const freezedArray = Object.freeze(["Вася"])
freezedArray[0] = "Петя" // Ошибка компиляции. Если все же запустить, то и во время выполнения.
Как видим, проблем в ФП с инкапсуляцией нет, и все сценарии довольно просто реализуются без дополнительных символов в виде модификаторов доступа. Но стоит отметить, что часто инкапсуляция не только не нужна, но и даже вредна — увеличивает количество кода, усложняет тестирование, замедляет работу приложения.
Итог: ООП не реализует инкапсуляцию хоть как то лучше, чем ФП.
С основными отличиями закончили, но давайте еще пройдемся по типичным проблемам, часто возникающим в связи с использованием ООП:
Синтаксис языка
Языки ООП чрезмерно усложнены избыточным синтаксисом, появившимся в результате попыток разработчиков языка решить заложенные в ООП проблемы. Решить частично, так как полностью решить архитектурно заложенные проблемы невозможно. Классы, абстрактные классы, статические классы и методы, конструкторы, наследование, интерфейсы, различные перегрузки методов, геттеры/сеттеры, реализации методов в интерфейсах по умолчанию, модификаторы доступа, аннотации/атрибуты и многое другое — кривая обучения языков ООП очень сильно усложняется. Кроме того, они часто дублируют функционал друг друга, заставляя разработчиков еще больше ломать голову, выбирая наименее худший вариант. Довольно большую часть разработки начинает занимать не решение бизнес задач, а борьба с языком и его ограничениями.
Синтаксис языков ФП намного проще (особенно если это не язык в радикально математическом стиле), в нем отсутствует практически всё ранее перечисленное.
Шаблоны (паттерны) проектирования
То же самое можно сказать и про огромное количество паттернов с умными названиями, по ним написано множество книг, их очень любят спрашивать на собеседованиях. Но по большому счету паттерны ООП это просто костыли, также "героически" частично решающие одну из заложенных в ООП проблем (пример - Декоратор расширяют класс, когда наследовние недоступно).
В ФП, зная эти приемы — 1. добавить аргумент у функции; 2. использовать замыкание; 3. обернуть функцию в другую — ты уже знаешь все основные паттерны.
Конструкторы
В большинстве ООП языков постоянно требуется реализовывать конструкторы с типичным бойлерплейтом. В ФП это довольно редкое явление, так как данные отделены от логики, и в большинстве случаев создание сущности какого-либо типа это просто создание стандартных структур данных, таких как строка, массив, ассоциативный массив:
type User = {
id: string
firstName: string
lastName: string
middleName?: string
friendIds?: string[]
}
// Не обязательно создавать функцию-конструктор.
// Компилятор укажет на проблемы, если типы предоставляемых полей не совпадают.
const user: User = {
id: "1",
firstName: "Вася",
lastName: "Петров",
friendIds: ["2"]
}
Более того, следующий пункт про то, что оказывается и использование конструкторов в ООП является антипаттерном.
Контейнеры и внедрение зависимостей
В отличие от ФП, где большинство кода расположено в функциях, которые чаще всего просто экспортируются и импортируются, в ООП обычно большая часть кода расположено в нестатических классах, которые нужно инициализировать. Чтобы решить очередную заложенную в ООП проблему и инициализировать объекты классов удобнее и настолько гибко, насколько никогда не потребуется, были придуманы контейнеры зависимостей. Кратко — оказывается использование конструкторов — антипаттерн (а в ООП бывает по другому?), так как рано или поздно придется прокидывать все зависимости во все экземпляры классов, поэтому лучше прокинуть один контейнер зависимостей и инициализировать объекты только через него. Более того, а должен ли класс вообще знать что он — синглтон? Для идеальной гибкости конечно же нет. Вдруг кому то когда то понадобиться сделать синглтон не синглтоном? Такого еще в истории не было ни разу, но почему бы не написать еще больше кода, еще сильнее усложнив его.
В ФП конечно тоже может быть ситуация, когда параметров функций становится слишком много, и было бы неплохо связать часть функций с частью аргументов (зависимостей), но делается это только по мере необходимости.
// Большинство кода хранится в функциях, не имеющих состояния, и не требующих ни инициализации, ни создания синглтонов, ни контейнера зависимостей.
export const getDisplayName = ...
// Синглтон — просто экспортированная инициализированная сущность, без костылей с инициализацией статического Instance.
export const store = createStore(...)
const main = () => {
// Если импортирование store не устраивает, импортируем функцию createStore.
const store = createStore()
someWork(store)
// Если не хотим далее передавать store через аргументы, может быть использовано, например, замыкание.
const someWorkWithStore = () => someWork(store)
// Далее используем без передачи store.
someWorkWithStore()
}
С тестируемостью при этом все в порядке - любой импорт в TypeScript можно подменить в тестах без проблем.
Сериализация и копирование
Так как в ФП данные отделены от логики и в основном являются либо встроенными типами, либо составными из встроенных, то эти данные чаще всего являются сериализуемыми по умолчанию, а также копируемыми поверхностно и глубоко без лишнего кода:
const user: User = {
id: "1",
firstName: "Вася",
lastName: "Петров",
friendIds: ["2"]
}
// Поверхностное копирование с изменением и добавлением полей.
const updatedUser: User= {
...user,
firstName: "Василий",
middleName: "Иванович", // Добавляем опциональоне поле.
}
// Глубокое копирование.
const clonedUser: User = structuredClone(user)
// Сериализация в JSON.
const userJson: string = JSON.stringify(user)
// Десериализация из JSON.
const parsedUser: User = JSON.parse(userJson)
В ООП языках для этого часто приходится реализовывать функции сериализации и клонирования в каждом отдельном классе, что в очередной раз сказывается как на скорости разработки, так и на багоемкости кода.
Работа с массивами
Класс — данные и методы для работы с ними. И следуя этой логике программисты часто пишут методы для работы, например, с User — в классе User, что вроде бы логично, но что если в будущем нужно работать с несколькими пользователями?
class User {
update() {
service.updateUser(this.id, …)
}
}
// Правильно ли обновлять массив user таким образом?
// Что тут должен придумать неокрепший ум начинающего ООП-шника?
for (let user of users) {
user.update()
}
Часто здесь начинается вакханалия с костылями-паттернами по типу батчинга, чтобы оптимизировать это в один запрос вместо множества.
Банальное решение из ФП — писать код сразу для массива и использовать в том числе для одиночных объектов:
const updateUsers = (data: User | User[]) => {
const users = Array.isArray(data) ? data : [data]
// Работаем дальше с массивом, посылая один запрос.
}
Несколько моделей
Данную проблему можно наблюдать в пункте про полиморфизм — ООП провоцирует создавать отдельную доменную модель, отличную от той, что используется для передачи данных, например от бэкенда. Данные приходят сериализуемыми в определенном формате, а в ООП все нужно держать в классах с методами.
В ФП тоже можно использовать собственную модель, но во многих случаях трансформировать данные либо не нужно совсем, либо минимально, так как они и там и там "тупые" и сериализуемые. Редко когда возникает необходимость в создании отдельной доменной модели приложения, и используется просто модель с бэкенда вплоть до слоя UI, в идеале автосгенерированная.
Кто то скажет что это не гибко, и при изменении модели бэкенда придется переписывать и код приложения. Но на самом деле его придется переписывать в обоих случаях, только в случае с отдельной моделью начинает копиться технический долг если это сделать не сразу, и то что делают ООП-шники это скорей очередной оверинжиниринг. Более того, изменение нескольких полей в коде приложения делается через рефакторинг в IDE за 5 сек, в отличие от написания тысяч строк бесполезного кода.
Конкурентность, многопоточность
Огромным преимуществом математического стиля ФП является поддержка конкурентности (concurrency) без дополнительных усилий и синхронизаций. В ООП принято работать в изменяемом стиле, поэтому приходится писать очень сложный и багоемкий код синхронизации доступа к данным. В недавней статье я даже писал, что до сих пор большинство программистов, включая создателей популярных языков программирования, в этом плохо разбираются.
Выводы
Как мы видим, ООП не только не решает ни одной проблемы лучше чем ФП, но и создает огромное множество других, причем полностью нерешаемых никакими костылями-паттернами. Оно требует знание и использования огромного количества таких паттернов, многие из которых запрещают использование даже базовых возможностей классов, таких как конструкторы или наследование. Многие ООП-разработчики уже либо позабыли зачем они все это делают, либо никогда и не знали, сразу погрузившись в дебри фреймворка и делая "потому что так принято". По итогу мы имеет намного больше уродливого, переусложненного кода, который можно кратко описать как "монструозный набор костылей".
Знали ли про эти недостатки в первые годы зарождения ООП языков?
-
Многие мастодонты программирования, такие как Линус Торвальдс, сразу же пришли к похожему выводу, и последний запретил использование языка C++ в ядре Linux.
-
Даже создатель Java вскоре признал, что не стал бы добавлять классы, если бы создавал язык второй раз (позже смягчив, что наследование лучше не использовать).
Но были и те, кто обрели невероятную популярность, описывая принципы и паттерны ООП. Одним из самых популярных программистов, при этом невероятной степени говнокодером, пожалуй является Роберт Мартин с его принципами ООП SOLID и книгой о чистом коде. В этой статье можно оценить, насколько убого выглядит "чистый" код данного "гуру" Java в сравнении с простой функцией на TypeScript, и сделать несложные выводы.
Откуда такая популярность?
Все таки есть некоторое ощущение, учитывая как много нынче языков с ООП и программистов их использующих, что популярность ООП держится не только на безумно широкой рекламной компании Java от Oracle в свое время, и том факте, что 99% людей идиоты имеют IQ<140. И действительно, есть один "плюс" — это…
Автокомплит - наличие возможности посмотреть, какие функции можно вызвать с определенным типом данных настолько удобен пользователям, что большинство готовы терпеть все прочие недостатки (в абсолютном большинстве случаев их не осознавая — см. пункт про 99% людей).
Вот только суть в том, что автокомплит от точки это не особенность языка или даже парадигмы программирования. Это 100% особенность сред разработки. Но неужели те, кто занимается ФП не додумались сделать что то подобное? Как ни странно (насколько мне известно) большую часть времени действительно такой возможности для функциональных языков не было. Например в том же Haskell в одно время появился сайт Hoogle для поиска функций по имени или примерной сигнатуре, но одно дело видеть через долю секунды в IDE, другое — лезть на сайт. А требовать запоминания тысяч функций для всех типов вместо удобной подсказки — крайне серьезный недостаток использования ФП в большинстве популярных IDE.
Сегодня конечно же есть плагины VS Code для Haskell и других языков, позволяющих удобно искать функции, предоставляя один или даже несколько параметров из сигнатуры функции, но в том же VS Code по умолчанию ни для JS, ни для TS (в сумме самые популярные языки программирования) данной возможности нет из коробки.
Можно было бы также обсудить и сам вызов методов через точку, появившийся еще в Smalltalk в 1970-ых годах, откуда он и перекочевал в большинство современных языков. Именно он провоцирует два способа вызова функций — с точкой, когда по факту первый аргумент передается отличным от остальных способом, и без — когда все аргументы передаются одинаково.
Сам факт наличия двух способов сделать одно и то же — уже показатель плохой архитектуры и дополнительной головной боли для разработчика, и соответственно места для неудачного решения.
То же можно сказать и о скобках — изначально решение довольно неудачное, так как несколько вызовов подряд выглядит нечитаемо, и как раз они делают неудобным автокомплит для аргументов в ФП, без точки:
thirdCall(c, secondCall(b, firstCall(a)))
Часто в подобных случаях можно увидеть реализации цепочки вызовов путем возвращения объектов, но это лишь очередной костыль и очередная возможность делать то же самое другим способом:
firstCall(a)
.secondCall(b)
.thirdCall(c)
Чем лучше заменить вызов функций через точку и скобки для аргументов предлагаю обсудить в комментариях.
Какой язык я бы хотел выделить из современных и популярных, в котором решены многие их перечисленных проблем?
-
В Go полностью отсутствуют классы, хотя есть методы интерфейсов с возможностью вызова через точку. Я полагаю создатели языка хотели, чтобы язык стал максимально популярным, и решили оставить этот недостаток, чтобы сразу не распугать всех джавистов.
-
TypeScript — сегодня на нем можно написать огромные приложения вообще не используя классы, и почти все лишнее отключить через линтер. Это один из самых удобных языков для ФП, в том числе с точки зрения возможностей типизации — одной из самых гибких и строгих, оставляя Java, C# и др. на десятилетие позади.
-
Конечно же C — отец всех С-подобных языков, и лишенный недостатков своего искалеченного ООП потомка, до сих пор еще актуален.
Итог
Я абсолютно уверен, что человек, пишущий коммерческий код на одном из чисто ООП-шных языков более 3-4 лет, и который не заметил множество его проблем и не начал думать о переходе или переходить на ФП — не может считаться крутым инженером. Настоящий инженер всегда думает о самых простых решениях, замечает изъяны и усложнения, и такое бревно в глазу никак не сможет пропустить.
Среды разработки, к сожалению, даже сегодня, в 2025 году, сильно заточены под ООП и не поощряют ФП, и уже нескольких десятилетий провоцируют худшие практики программирования. Виноваты в этом такие компании, как Microsoft и JvmBrains — одни из создателей тех самых искалеченных ООП языков и сред разработки. Эти компании, а также Apple и Google, продолжают создание языков ООП типа Swift, Dart, Kotlin, поэтому не только современное программирование далеко от науки — даже созданием современных языков занимаются люди, далекие от нее. Так что еще не скоро ситуация исправится.
Но все же прогресс есть, и даже ООП-шные языки постепенно внедряют функциональные практики, и уже есть языки, практически избавленные от перечисленных проблем.
Совет
..от человека, что уже много лет пишет код без классов: используйте языки, где нет классов (Go), отказывайтесь от классов если есть такая возможность (TypeScript, Python), отказывайтесь от языков, где классы это основа (Java, C#, C++ и др.). Пишите качественный — максимально простой, функциональный код.
Карма отрицательная, отвечаю крайне редко.
Автор: gen1lee