Найти тему

Как понять в программировании всё? (8)

Предыдущая серия:

https://zen.yandex.ru/media/id/5dad67587cccba00adeadb8d/kak-poniat-v-programmirovanii-vse-7-5fccab0a702d845a131731d2

  1. Абстракция данных

Абстракция данных -- это способ организации структур данных по чётким формальным правилам, которые гарантируют, что эти структуры будут использоваться в системе корректно.

Абстракции данных имеют внутреннюю и внешнюю части и интерфейс к ним. Все физические структуры данных (поля, атрибуты конкретных типов) хранятся внутри абстракции, и эта внутренняя часть скрыта от внешней. Все операции с частями выполняются исключительно через интерфейс, обеспечивающий доступ к внутренней и внешней частям.

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

У этого подхода три преимущества:

1) Гарантируется, что абстракция данных работает корректно. Интерфейс задаёт набор авторизованных операций над структурой данных, и никакие другие действия над ней не допустимы.

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

3) Мы получаем возможность разработки очень больших программ. Мы можем разделять процесс разработки между большим количеством людей. За каждую абстракцию ответственен конкретный человек, который занимается её разработкой и сопровождением. Ему при этом требуется лишь знать интерфейсы, которые используются в его абстракции.

В частности, объектно-ориентированное программирование базируется на абстракциях данных с наследованием и полиморфизмом.

2. Объекты и абстрактные типы данных (АТД)

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

Первая ось -- это состояние; использует ли абстракция именованное состояние, или нет.

Вторая ось -- это свёртка/пакет/комплект/бандл (bundling). Она определяет, объединяет ли абстракция физические данные и операции над ними в одну сущность (это, например, обычный класс/объект в ООП), или же сохраняет их разделёнными (тогда это называется абстрактный тип данных; в программировании на него похож абстрактный класс или интерфейс).

В результате получаются четыре комбинации. Две из них широко используются в популярных языках программирования.

a) Объединив пакет с именованным состоянием, получим классический, чистый объект в Java или Python: внутренние поля-идентификаторы задают состояние, а методы -- это выполняемые над ним операции.

b) Противоположный случай (отсутствие именованного состояния и отсутствие свёртки), когда мы используем абстрактный тип данных, отделённый от операций -- это например тип Integer в Java. Целые представляются конкретными значениями (1, 2, 3, ...), которые можно передавать в качестве аргументов внешним операциям (+, -, *, / ...). При этом какие-либо "промежуточные" состояния значений (непосредственных чисел 1,2,3,...), очевидно, не допускаются. Так мы имеем чистый АТД.

Два других варианта используются гораздо реже. Это

c) АТД с именованным состоянием (например, частично реализованный класс), и

d) так называемый декларативный объект (когда есть только описание объекта, но состояния он не содержит).

3. Полиморфизм и принцип ответственности

Важнейший принцип ООП (после поддержки абстракций данных самих по себе) -- это полиморфизм. Мы говорим, что сущность полиморфна, если она может принимать различные формы. В программировании мы говорим, что сущность полиморфна, если она может принимать аргументы различных типов.

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

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

Например, пациент обращается к доктору с единственным сообщением «ВылечиМеня». Какой бы доктор не получил это сообщение, он займется лечением, но с учётом своей специализации. То есть "программа" пациента «ВылечиМеня» полиморфна: она может работать с врачами всех типов, потому что все врачи понимают сообщение «ВылечиМеня» (но каждый тип врачей -- по своему).

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

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

В наивном ООП под полиморфизмом понимается такая схема:

Animal a;

a = Cat();

a.Run();

В переменной родительского типа хранится объект конкретного дочернего типа, и во время выполнения будет вызван именно конкретный котиков метод Run(). Но при этом родительский класс Animal конечно обязательно содержит свой метод Run() (в идеале, абстрактный), и правильно сам класс Animal «реализовывать» и воспринимать как абстракцию данных.

Во взрослом ООП под полиморфизмом понимается немного другое:

Пациент Джон = Пациент(...);

Доктор Мартин = Психиатр(...);

Джон.ВылечиМеня(Мартин);

Методу пациента ВылечиМеня() можно передавать не просто наследников класса Доктор (как в наивном ООП), но и любые другие классы, с классом Доктор вообще не связанные (например, НародныйЦелитель :) , но поддерживающие (реализующие) интерфейс, требуемый методом ВылечиМеня().

При этом (важно) «ВылечиМеня» следует рассматривать не столько как метод Пациента, сколько как отправку сообщения «ВылечиМеня» Доктору.

Занудно говоря, это всё про ad-hoc и параметрический полиморфизм, которые неплохо объединяются в одну модель через тайпклассы/категории. А вообще различных видов полиморфизма, различающихся гораздо более мелкими нюансами, весьма немало, и многие из них ортогональны, к счастью. Более подробно разбираем это всё на следующих курсах по парадигмам программирования.

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

4. Наследование и принцип подстановки

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

Наследование позволяет определять абстракции последовательно, постепенно. Определение А берёт определение B как базу и модифицирует его, уточняет или расширяет (определение с такими возможностями мы называем «класс»). Однако у наследования имеется такой недостаток, что расширение определения B может рассматриваться как ещё один, дополнительный к родительскому, интерфейс к B, и этот интерфейс должен поддерживаться и сопровождаться на протяжении всего срока службы B, как и интерфейс А. Но это тоже путь к потенциальным ошибкам.

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

Если же наследование используется, то надо осознанно придерживаться принципа подстановки.

Допустим, имеется класс B, унаследованный от класса А. OA -- экземпляр класса A, и OB -- экземпляр класса B. Принцип подстановки подразумевает, что любая функция, работающая с объектом OA класса A, может также работать и с объектами OB класса B. Другими словами, наследование не должно менять родительские интерфейсы, и можно свободно заменить всё прямое использование класса A в программе на использование класса-предка B.

Это так называемый принцип подстановки Барбары Лисков из правил SOLID (Liskov Substitution Principle, LSP): потомки не должны "ломать" базовую логику, предоставляемую предками, но при этом должны обеспечивать полноценный интерфейс к этой базовой логике.

В общем случае, вместо наследования лучше использовать композицию. Это естественный подход, когда атрибут объекта ссылается на другой объект. Объекты объединяются вместе, и не возникает потребности в расширении класса через наследование. Если всё же наследование приходится применять (как правило, когда речь явно идёт об отношениях между классами, а не между объектами), то правильный способ делать это -- придерживаться принципа подстановки.

Подробнее все эти фишечки и нюансы разбираем на моих курсах по объектно-ориентированному проектированию.

продолжение следует

В следующей заметке подробнее разберёмся, что же делать с недетерминированным параллелизмом, который характерен для всех популярных языков (Java, Python, C шарп, PHP ...), где одновременное выполнение пытаются скрестить с именованными состояниями.

Высшая школа программирования Сергея Бобровского