Как стать автором
Обновить

ООП. Да что же ты такое?

Уровень сложностиСложный
Время на прочтение9 мин
Количество просмотров1.8K

Определение 0 из Вики

Объе́ктно-ориенти́рованное программи́рование (сокр. ООП) — методология или стиль программирования на основе описания типов/моделей предметной области и их взаимодействия, представленных порождением из прототипов или как экземпляры классов, которые образуют иерархию наследования

Вам нравится? Мне нет!

Классы и наследование? Всё? Кто это вообще придумал?

Определение 0.1 от автора

Автором или одним из «отцов-основателей» ООП считается Алан Кэй

«Я сожалею о том, что давным-давно придумал термин «объекты» для этого явления, так как его использование приводит к тому, что многие люди уделяют основное значение идее, которая не так важна, как основная. Основная идея — это обмен сообщениями». Алан Кэй

«ООП для меня означает лишь обмен сообщениями, локальное сохранение, и защита, и скрытие состояния, и крайне позднее связывание». Алан Кэй

Подробней тут

Определение 0.2 от популяризатора

Слово "объектно-ориентированный" стало в области программирования синонимом слово "хорошо". В печати чаще всего встречаются элементы такого вида:

  • Ada - это хорошо;

  • объектной ориентированности - это хорошо;

Отсюда следует, что Ada - объектно-ориентированный язык.

Серьёзно? Бьёрн Страустрап так и не дал определения ООП?

Прагматизм Страустрапа: Он всегда был больше прагматиком, чем теоретиком-классификатором. Его интересовало, как использовать определённые возможности языка (которые ассоциируются с ООП) для решения практических задач: улучшения структуры кода, управления сложностью, создания переиспользуемых компонентов.

Откуда же взялись то что дают в учебниках: инкапсуляция, абстракция, наследование и полиморфизм?

Концепции, которые мы сегодня связываем с ООП, развивались постепенно в разных языках и сообществах. Simula (1960-е) ввела классы и наследование. Smalltalk (1970-е, Алан Кэй) сделал упор на объекты и передачу сообщений (message passing) как фундаментальную идею. C++ (1980-е, Страустрап) принёс ООП-возможности (вдохновленные Simula) в мир C, сделав их мейнстримом, но с акцентом на производительность и статическую типизацию.

Определение 1 из учебников

ООП это стиль программирования, использующий объекты. Объект включает в себя данные и методы.

Наиболее заметные и общие механизмы, присутствующие в этих популярных ООП языках:

Абстракция: Возможность моделировать сущности реального мира или концепции, выделяя главное и опуская детали.

Инкапсуляция (и сокрытие данных): Объединение данных и методов для работы с ними в единый блок (класс) и ограничение прямого доступа к внутреннему состоянию (через private/protected)

Наследование: Механизм создания новых классов на основе существующих, позволяющий переиспользовать код и строить иерархии типов ("is-a" relationship). Это была очень видимая и активно продвигаемая вначале фича.

Полиморфизм: Способность объектов разных классов отвечать на один и тот же вызов метода (сообщение) по-своему (часто через виртуальные функции или переопределение методов). Это позволяло писать более гибкий и расширяемый код

Собственно, это всё. Определения нет. Есть только общие свойства реализации языков, которые практики отнесли к ООП.

Определение 3 сложное

Все ли определения либо не полны либо опираются на другие определения либо являются полной чушью. Нужно вводить термины постепенно, показывать цель а не догму.

Let's go deeper!
Let's go deeper!
  1. ООП (Объектно-Ориентированное Программирование) — это подход к организации программ, основанный на концепции объектов. Хи-хи, очевидно же!

  2. Объект:

    • Сущность, обладающая состоянием (внутренними данными) и поведением.

    • Поведение объекта зависит от его текущего состояния и реализуется через методы. Поведение и состояние это наблюдаемые признаки, очень полезно при обследовании и проектировании. Расшифровка этих терминов не нужна: what you see is what you get.

    • Метод связан со стрелой времени. Хи-хи, простите, но проще не смог. Применение метода разделяет наблюдаемые признаки объекта или его контекста на "до" и "после". Ключевое отличие от чисто функционального подхода: методы в ООП  могут менять внешний контекст или состояние своего объекта. Это основной механизм управления жизненным циклом и данными объекта. Метод ментально предоставляется наблюдаемым действием/изменением. Метод как и объект это антропоморфизмы, метод/изменение наблюдаемы. Функция же это абстрактное связывание, составление и потому труднее в понимании, она не наблюдаема.

  3. Класс: Шаблон для создания объектов (экземпляров), описывающий их структуру (логическую и частично физическое представление в компьютере), состояния и набор методов.

  4. Инкапсуляция (Контроль Состояния через Контракт):

    • Состояние скрыто; взаимодействие объекта идёт через публичные методы (контракт). Слово контракт тоже в расшифровке не нуждается

    • Цель: Объект сам отвечает за изменения состояния (выполняемые его методами), обеспечивая целостность. А если инвариант находится на стыке 2 классов, то приходится создать третий, который будет за него отвечать. Single responsibility (ответственность) из SOLID - чисто ООП заморочка, в ФП нет понятия ответственности. Впрочем, L, I тоже вне ООП не имеют смысла. Итак, для ООП уже придумали некие принципы, дело становится всё более запутанным!

  5. Абстракция (Независимость Контракта от Реализации):

    • Контракт не зависит от внутренней реализации методов и структуры состояния.

    • Цель: Реализацию можно менять без изменения контракта, код пользователя объекта не ломается.

  6. Полиморфизм: Возможность работать взаимозаменяемо с объектами разных классов через только ту часть контракта, которая нужна сейчас. Кстати, интерфейсы для полиморфизма это костыль: чтобы использовать часть контракта класс должен изначально знать об этой части (интерфейсе). Утиная типизация здесь более удобна. Использование максимальной абстракции, только нужной части контракта это Stable Abstractions Principle. Под руку с ним ходит очевидный Stable Dependencies Principle - чем стабильнее компонент тем он должен меньше зависеть от нестабильных. Заметили? Нам приходится опять ссылаться на сложные понятия! ООП чертовски сложен!

  7. Наследование это полиморфизм подтипов (чуть ли не самый бестолковый). А всего можно выделить 4-5 видов полиморфизма в зависимости от настроения: adhoc (перегрузка методов), параметрический / истинный (например, дженерики), подтипов (наследование), приведения типов (coercion)

Нет, Ватсон, вы видели?! Видели?!

Это я ещё в проблемы и сложности наследования не полез. В результате, некоторые языки вообще запретили или сильно ограничили наследование.

Итог по ООП:

ООП позволяет строить программы из объектов, которые:

  • Сами отвечают за свое состояние и его целостность.

  • Предоставляют стабильный контракт, не зависящий от внутреннего устройства.

  • Строятся по шаблону.


Шаблоны банды 4х

Ктулху
Ктулху

Ну, этот ад можно только вызубрить, но не понять. Шаблон на шаблон требует ещё шаблон для снижения сложности.

Почему так сложно?

Слишком большая связность данных и методов, слишком либеральные возможности мутации данных, слишком крупный неразделимый атом.

Важное примечание об альтернативных подходах к корректности данных:

  • Метод ООП для обеспечения корректности данных — это инкапсуляция: сокрытие состояния и контроль его изменений через методы контракта.

  • Существует и другой подход защиты данных, являющийся основой функционального программирования (ФП): неизменяемость (иммутабельность). Вместо контроля над изменениями состояния используется полный запрет на изменение существующих данных. Если нужно "изменение", создаётся новая версия данных. Оба подхода стремятся к надежности и предсказуемости работы с данными, но достигают этого разными путями.


ФП менее антропоморфен, неочевиден на уровне рефлексов (хотя таким математикам как я этот способ более очевиден). Это ступенька, которую надо преодолеть вначале. Но после этого шага ФП выходит на плато сложности и дальше уже не мешает.

Симметрия ООП и ФП

Проблема выражения это сложная проблема в языках программирования , которая касается расширяемости и модульности статически типизированных абстракций данных. Цель состоит в том, чтобы определить абстракцию данных, которая является расширяемой как в своих представлениях, так и в своих поведениях.

Представим себе таблицу

Типы = Столбцы, Функции = Строки (Или наоборот, как хотите). Давайте разберемся, как добавление строк и столбцов выглядит в разных парадигмах при таком раскладе.

Примеры

# типы (классы)
class Cat:
    def speak(self): return "Мяу"

class Dog:
    def speak(self): return "Гав"

# теперь нужно для всех типов добавить новую функцию feed():
# придется модифицировать каждый класс (Shotgun surgery)
class Cat:
    def speak(self): return "Мяу"
    def feed(self): return "Кормим кошку"

class Dog:
    def speak(self): return "Гав"
    def feed(self): return "Кормим собаку"

Как смягчить проблему в ООП:

Использовать паттерн посетитель или двойная диспетчеризация

class Animal:
    def accept(self, visitor):
        visitor.visit(self)

class Cat(Animal): pass
class Dog(Animal): pass

class Visitor:
    def visit(self, animal):
        method = getattr(self, "visit_" + animal.__class__.__name__)
        return method(animal)

class FeedVisitor(Visitor):
    def visit_Cat(self, cat): return "Кормим кошку"
    def visit_Dog(self, dog): return "Кормим собаку"

cat = Cat()
print(cat.accept(FeedVisitor()))

Пример для ФП (когда неудобно добавлять новый тип данных):

trait Animal {
    fn speak(&self) -> &str;
}

struct Dog;
struct Cat;

impl Animal for Dog {
    fn speak(&self) -> &str { "гав" }
}
impl Animal for Cat {
    fn speak(&self) -> &str { "мяу" }
}

// при добавлении нового метода feed, придется менять каждый тип (shotgun surgery):
trait Animal {
    fn speak(&self) -> &str;
    fn feed(&self) -> &str; // изменяем много мест
}

impl Animal for Dog {
    fn speak(&self) -> &str { "гав" }
    fn feed(&self) -> &str { "кормим собаку" }
}

impl Animal for Cat {
    fn speak(&self) -> &str { "мяу" }
    fn feed(&self) -> &str { "кормим кошку" }
}

Как смягчить проблему в ФП:

Использовать pattern matching и enum для небольшой "функционально-ориентированной" структуры:

enum Animal { Dog, Cat }

fn speak(animal: &Animal) -> &str {
    match animal {
        Animal::Dog => "гав",
        Animal::Cat => "мяу",
    }
}

// легко добавлять новые функции без модификации типов самим по себе:
fn feed(animal: &Animal) -> &str {
    match animal {
        Animal::Dog => "кормим собаку",
        Animal::Cat => "кормим кошку",
    }
}

Выводы к аналогии (Типы=Столбцы, Функции=Строки):

ООП: Легче добавлять/изменять типы (столбцы) не меняя старый код, но сложнее добавлять новые функции (строки), которые должны работать со многими типами, так как это требует модификации этих типов (столбцов). Организация "по столбцам".

ФП: Легче добавлять новые функции (строки), которые работают с существующим набором типов, но сложнее добавлять новые типы (столбцы), так как это требует модификации многих существующих функций (строк). Организация "по строкам".

Паттерны Банды Четырех (GoF): Они действительно являются квинтэссенцией решения классических проблем внутри ООП-парадигмы. Паттерн Посетитель (Visitor) — это прямое решение проблемы добавления новой операции (строки) к иерархии классов (столбцов) без их изменения. Фабричные методы, Абстрактная фабрика, Строитель — помогают управлять созданием объектов (добавлением/вариацией типов/столбцов). Декоратор или Стратегия позволяют гибко добавлять/изменять поведение (функции/строки) для объектов. Все они существуют во многом потому, что базовые механизмы ООП не всегда элегантно справляются с обоими видами расширений одновременно.

Мир не совершенен
Мир не совершенен

P.S

Habr, что это за "Целевая аудитория"?

Статья предназначена для яйцеголовых преподавателей разработки программного обеспечения, которые должны дать отеческое наставление юным программистам, выбирающим свой путь.

P.S. по просьбам трудящихся: как с ООП выстрелить в ногу.

import secrets

class EmailConfirmation:
    def __init__(self):
        self._token = self._gen_token()

    def _gen_token(self) -> str:
        # генерируем 16-символьный случайный код
        return secrets.token_hex(8)

    @property
    def token(self) -> str:
        """
        Геттер вроде бы просто «отдаёт токен»,
        но заодно каждый раз генерирует новый!
        """
        self._token = self._gen_token()   # ← побочный эффект
        return self._token

Как это стреляет в ногу

confirm = EmailConfirmation()

# 1) Записываем токен в БД
db_token = confirm.token          # ← сгенерировался код А

# 2) Тут же отправляем письмо
send_email(to="user@mail.com",
           body=f"Ваш код: {confirm.token}")   # ← уже код B !

# 3) Пользователь переходит по ссылке с кодом А,
#    а мы в БД держим код B — подтверждение ломается.

Вызов confirm.token дважды подряд дал два разных значения, потому что геттер мути­ро­вал скрытое состояние. Снаружи это выглядело как «обычное чтение свойства».

Запрет скрытого мутирования или побочных эффектов теоретически возможен и решил бы проблему. Но ни один популярный язык этого не делает. Видимо, сложно в реализации.

Что демонстрирует пример

В классическом ООП язык никак не запрещает вам заложить побочный эффект в геттер/сеттер. Приставка @property или название getX() не гарантируют, что метод чистый.

Клиентский код исходит из предположения, что «получение атрибута» побочных эффектов не имеет, и строит выражения вроде

if obj.token == obj.token:

которые для такого класса всегда ложно.

Ошибка обнаружится только в рантайме (и, скорее всего, на проде), потому что: • компилятор не отличает «чистую» функцию от «грязной»,

• типовая система не отражает факт мутации.

Почему так трудно промахнуться в функциональном ядре

Если ту же задачу описать через неизменяемую структуру + явную функцию, эффект виден «в сигнатуре»:

from dataclasses import dataclass
import secrets

@dataclass(frozen=True)
class Confirmation:
    token: str

def regenerate(c: Confirmation) -> Confirmation:
    return Confirmation(token=secrets.token_hex(8))

У функции regenerate другое имя, поэтому случайно вызвать её «для чтения» нельзя.

• Объект Confirmation иммутабелен (frozen=True), внутри него ничего не меняется самопроизвольно.

• Любая утечка состояния сразу видна в ­код-ревью — вызывающий должен явно присвоить результат новой переменной.

Вывод

Мутирующие геттеры показывают один из практических недостатков «чистого» ООП в мейнстрим-языках: отсутствие языковой/типовой защиты от скрытых side-effects в методах, которые по названию или аннотации выглядят как обычные accessor’ы.

Функциональный подход (ADT + чистые функции) делает такие эффекты явными и, как правило, ловит подобные ошибки на этапе компиляции или хотя бы в юнит-тестахУ

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
ООП это просто?
50% Просто15
10% Сложно3
16.67% Ужасно5
26.67% Не использую / стараюсь не использовать8
10% Кто здесь?3
20% В смысле? Без ООП вообще никто не пишет6
Проголосовали 30 пользователей. Воздержались 4 пользователя.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Написать статью про ФП в таком же стиле?
41.94% Да13
22.58% Нет7
35.48% Не пиши больше никогда, ты больной на всю голову!11
6.45% Дратути!2
Проголосовал 31 пользователь. Воздержались 2 пользователя.
Теги:
Хабы:
-5
Комментарии38

Публикации

Ближайшие события