Комментарии 26
> За годы преподавания и коммерческой разработки я повстречал много студентов и разработчиков с одним и тем же заблуждением насчет ООП: класс = абстракция.
Через несколько абзацев.
> Классы — это, по сути, абстракции механизмов группировки и обобщения человеческого мозга.
Вы меня извините, но я все еще продолжаю считать класс абстракцией.
Вы бы лучше в статье исправили акценты, а-то у меня лично создалось впечатление, что мне сейчас статьей неоспоримо на пальцах докажут то, что класс != абстракция ни при каких обстоятельствах.
Когда человек пишет код, для него естественнее двигаться снизу вверх — от более низкоуровневых компонент к более высокоуровневым.
Спорное утверждение. Проектирование «сверху вниз» тоже имеет место, и, как мне кажется, более плодотворно. Сначала продумываются интерфейсы, и потом уже делается их реализация, с постепенным увеличением количества деталей. Особенно этот подход справедлив в функциональных языках вроде Haskell, но и в объектно-ориентированных он тоже применим.
Спорное утверждение. Проектирование «сверху вниз» тоже имеет место, и, как мне кажется, более плодотворно. Сначала продумываются интерфейсы, и потом уже делается их реализация, с постепенным увеличением количества деталей. Особенно этот подход справедлив в функциональных языках вроде Haskell, но и в объектно-ориентированных он тоже применим.
Классический пример, программа Copy, описанная в статье The Dependency Inversion Principle
void Copy()
{
int ch;
while ((ch = Keyboard()) != EOF)
{
WritePrinter(c);
}
}
У меня есть метод для чтения с клавиатуры, метод для записи на принтер. Мне надо написать программу, которая читает с клавиатуры и выводит на принтер. Я взял буквально и написал. Здесь Keyboard() и WritePrinter() — более низкоуровневые компоненты, а Copy более высокоуровневый.Получили, что Copy жестко связана с низкоуровневыми компонентами. Обычно люди (с кем я сталкивался) так и поступают.
А принцип инверсии зависимостей как раз и говорит, что так делать не надо. Как раз и нужно продумывать интерфейсы для устройств четния и записи, а уж через них реализовывать Copy, то есть то, о чем Вы говорите.
статья, вероятно, хотела раскрыть тему роли абстракций в ООП. Наверное, это — один из способов частичного их выражения. Тема не раскрыта. Перечислены признаки абстракций, признаки ООП, ссылки на авторов, на принципы, отдельные упоминания принципов и терминов. А где вся тема? Где чётко очерченные границы вопроса?
В своей статье я хотел показать, что абстракции в ООП это следствия более общего механизма — абстрагирования, который свойственен вообще человеческому мышлению. Зачем это нужно знать? Многие ограничения классов, за которые их не любят, проистекают не из самой концепции классов, а из наших представлений о процессе мышления. Например, классы часто критикуют за то, что они жестко фиксируют набор методов, в то время как в жизни объект может менять свое поведение. Но, если призадуматься, например, когда я был маленький я представлял себе, что все дома параллелепипеды. Когда я вырос, и стал делать ремонт в квартире, то понял, как я жестоко ошибался. Но у меня по прежнему в голове сохранились эти две абстракции. Только теперь я знаю, когда удобнее пользоваться одной, а когда — другой. То есть не сама абстракция поменялась, а добавилась новая, плюс правило, когда их нужно применять. Так и в ООП: The Open-Colsed Principle, который фактически утверждает, что для нового поведения надо писать новый класс.
Википедия при немного большем объёме статьи даёт в несколько раз больше содержательной информации. en.wikipedia.org/wiki/Abstraction_%28computer_science%29. И список ссылок там гораздо ближе к теме.
Статья действительно хороша. Но она опять же она говорит про абстракцию именно «in Computer Science» и никакой связи с общим механизмом. Цель моей статьи показать эту связь.
На мой взгляд, понимание общих механизмов очень важно и сильно упрощает жизнь. Не от одного человека слышал, что самая большая польза от аспирантуры — это вступительный экзамен. Подготовка к нему позволяет структурировать и выстроить связи между изученным материалом.
… я хотел показать, что абстракции в ООП это следствия более общего механизма — абстрагирования
Неужели этого не понимают (студенты)? Абстрагирование — понятие изначально из философии, появление аналогичного термина в ООП наводит на мысль, что неспроста: ).
Но не суть. Основное, что я хотел бы сообщить с позиции стороннего наблюдателя — статья — в ряду таких же других, туманно объясняющих связь понятий абстракции, не вызывает эффекта «Вау, так вот для чего ООП!». Перечислили свойства абстракций (правильно, хорошо, находчиво выбраны примеры) — упомянули принципы ООП. Каждый читатель уныло отмечает: «Да, потому что так надо», откровения не происходит. Но Вы — не первый, так что всё нормально: ). Потому что ООП — конгломерат нескольких принципов (правил, паттернов — кому как угодно). Чтобы его понять, надо понять, почему все они сошлись в одном месте. (Я сам не могу показать, где и у кого это лучше читать, просто констатирую.)
Неужели этого не понимают (студенты)? Абстрагирование — понятие изначально из философии, появление аналогичного термина в ООП наводит на мысль, что неспроста: ).
К сожалению, не только студенты. Содержание курса философии и оценка тех или иных вещей сильно зависит от того, каких взглядов придерживается лектор. У нас, например, лекции начинал читать идеалист, а заканчивал читать ярый марксист. Надо сказать — тот еще коктейль получился. Но про абстракции речи не было.
Каждый читатель уныло отмечает: «Да, потому что так надо», откровения не происходит. Но Вы — не первый, так что всё нормально: ).
Но это не значит, то не надо пытаться.
P.S.: Жаль, что моя статья ничем Вам не помогла. Буду работать на улучшениями дальше
Но на мой взгляд про них лучше прочитать в статьях по первой ссылке.
Простите, не понял, что за первая ссылка?
Да как нефиг делать!
Если функции, изменяющие объект суперкласса, ломают контракт субкласса, — то нужно уточнить контракт этих функций, вот и всё.
Например, вместо требования
rect.setExtent(w,h); assert(rect.width() == w && rect.height() == h);
где rect — это или Rect, или унаследованный от него Square,
выдвинуть требование
-
if(rect.setExtent(w,h) == SUCCESS) assert(rect.width() == w && rect.height() == h);
— допустить возможность неудачи -
rect.setExtent(w,h); assert(max(rect.width(), rect.height()) == max(w,h));
— допустить неточное следование в желаемых рамках
Кстати об изменениях. В языке C++ есть такое явление, как срезка: когда объекту наследника присваивают значение предка, выполняется унаследованное присваивание предку, и в результате получается химера из старых и новых значений.
Это напрочь ломает принцип подстановки Лисков, но вполне вписывается в систему наследования.
… но вполне вписывается в систему наследования.
Наследник дополняет предка своими членами-данными и новыми функциями, переопределяет виртуальные функции (в этом месте соблюдение контрактов и следование LSP — личная ответственность программиста, а язык ему лишь помогает) и перегружает и прячет невиртуальные функции. В том числе, оператор присваивания.
Вот эта перегрузка-перепрятывание — это уже прямая поломка LSP.
template<class T> void assignBase(T& dst) { dst = Base(1,2,3); }
int main()
{
Base b; assignBase(b); // ok
Derived d; assignBase(d); // ошибка: нужная функция куда-то пропала!
}
Ну хорошо, наследник ещё и размером отличается от предка, и типом, поэтому передавать его в шаблон или в макрос — это не совсем тот случай, который имела в виду Лисков.
Давайте по ссылке передадим:
void assignBase(T& dst) { dst = Base(1,2,3); }
int main()
{
Base b; assignBase(b); // ok
Derived d; assignBase(d); // функция, оказывается, никуда не пропала, но возможно, работает не так, как мы хотим
}
И здесь уже всё зависит от того, устойчив ли наследник к срезке, или нет. Позволительно ли без ведома объекта поковыряться в его базе.
Язык здесь не препятствует возможным нарушениям LSP, а заодно затрудняет его соблюдение.
(Чтобы соблюсти, — возможно, придётся в операторе присваивания сделать вызов виртуальной функции — фактического обработчика присваиваний; а это громоздко).
Это я не в упрёк С++, а просто для того, чтобы подчеркнуть: наследование и субклассирование — это вовсе не одно и то же.
_____
Очень простой пример субкласса, безо всяких «квадрат-прямоугольник».
class Number
{
int value;
public:
explicit Number(int v) : value(v) {}
.....
};
class Number0To100 : Number // подкласс целых чисел
{
public:
explicit Number0To100(int v) : Number( (assert(v>=0 && v<100), v) ) {}
};
Для in-параметров — источники типа Number0To100 являются подклассом типа Number; но для out-параметров — наоборот, приёмники Number являются подклассом типа Number0To100.
Фигня в том, что наследование соединяет в себе свойства и источника, и приёмника.
Поэтому — или LSP, или вся полнота возможностей языка. Верёвка заряжена разрывными.
Я имею в виду, что наследование в С++ не является субклассированием в терминах Лисков.
Наследник дополняет предка своими членами-данными и новыми функциями, переопределяет виртуальные функции (в этом месте соблюдение контрактов и следование LSP — личная ответственность программиста, а язык ему лишь помогает) и перегружает и прячет невиртуальные функции. В том числе, оператор присваивания.
…
Поэтому — или LSP, или вся полнота возможностей языка. Верёвка заряжена разрывными.
Отвечу Вам словами Бьярна Страуструпа: "должна быть предоставлена возможность описать ожидаемое поведение объекта базового класса таким образом, чтобы можно было составить программу, ничего не зная о производном классе. " (Язык программирования С++. Специальное издание Глава 24. Проектирование и программирование. 24.3 Классы стр. 813). Автор C++ за LSP!
Срезка — это расплата за возможность работать с объектами по значению. "Этот эффект часто называют срезкой; он приводит к ошибкам и чреват сюрпризами. Одной из причин передачи указателей и ссылок на объекты в иерархии является желание избежать срезки." (Страуструп, Язык С++. Специальное издание. Глава 12. Производные классы. 12.2.3. Копирование стр. 355) Так что LSP еще одно подтверждение, что срезку стоит избегать.
Язык здесь не препятствует возможным нарушениям LSP, а заодно затрудняет его соблюдение.
Пример с квадратом и прямоугольником показывает, что нарушить LSP можно в любом ОО языке программирования, не вдаваясь в особенности синтаксиса.
LSP, если сравнивать со естественным языком, это одно из правил грамматики, которое накладывает ограничение на совместное использование слов (классов). Человек может писать с ошибками, игнорируя или просто не зная правила, и язык ему в этом воспрепятствовать никак не сможет.
(Чтобы соблюсти, — возможно, придётся в операторе присваивания сделать вызов виртуальной функции — фактического обработчика присваиваний; а это громоздко).
Насчет виртуальных функций в операторе присваивания — был такой прием — двойная диспетчеризация — это как то, о чем Вы пишите.
Если функции, изменяющие объект суперкласса, ломают контракт субкласса, — то нужно уточнить контракт этих функций, вот и всё.
Например, вместо требования
rect.setExtent(w,h); assert(rect.width() == w && rect.height() == h);
где rect — это или Rect, или унаследованный от него Square,
выдвинуть требование
if(rect.setExtent(w,h) == SUCCESS) assert(rect.width() == w && rect.height() == h); — допустить возможность неудачи
rect.setExtent(w,h); assert(max(rect.width(), rect.height()) == max(w,h)); — допустить неточное следование в желаемых рамках
Вообще-то, в примере из статьи The Liskov Substitution Principle функция, устанавливая высоту 5, а ширину 4, на выходе выдает прямоугольник площади 20! Поэтому, когда мы на вход подаем квадрат, получаем фигуру площади 25 или 16 в зависимости, какая из размерностей будет установлена последней. Ослабление постусловия в этом случае, приведет к потере всякого смысла от использования самой функции.
А если в контракте будет «вписать фигуру в указанные габариты» (например, мышкой за угол картинки в фотошопе потянули… а у ней пропорции залочены) — то всё честно: туда 4 и 5, обратно 4 и 4, или 5 и 5, по вкусу.
Еще одно важное наблюдение — невозможно по самим абстракциям определить насколько удачными они получились. Это можно сделать, только если мы попытаемся их использовать на практике. И тут уж выясняется, что одни абстракции лучше подходят для задачи, а другие — хуже. А если еще немного изменить исходные условия, то и прежний «хороший» набор абстракций уже может не работать.
Только Вы идете от обратного — если немного изменить исходные условия, то «плохие» абстракции могут вполне работать.
Еще можно предложить попытку поделить абстракции на исследовательские и инженерные. Исследовательские абстракции это совокупность ключевых элементов и связей, несущественных элементов и связей, и границы между ними по отношению к объекту.
А инженерные это в основном первое - близко к тексту любого учебника: все самое важное БЕЗ лишнего.
Нигде не встречал акцента на этом, но разница если подумать - чудовищна. Исследовательская абстракция, а точнее культура применения именно такого типа абстракций, подразумевает что все ее части заданы для носителя в явном виде, что он понимает перемещаемость границы, рефлексирует ее расположение. А человек с инженерной абстракцией - может владеть ею и ничем вышеперечисленным - одновременно.
Ну и можно предложить интересный взгляд на человеческую мифологию, на легенду о первородном грехе. Например, если предположить родовую травму человека - как неспособность оперировать полными представлениями о мире и его частях, а только лишь абстракциями (тоесть между представлениями человека о мире и самим миром всегда есть дистанция и она устраняема но не устранима ), то понятно почему бог наказал человека за попытку вкусить плод от древа познания. Не за то что тот приобщился к знанию вообще, а за то что посмел употреблять его фрагменты как законченный продукт, как представление о мире претендующее на полноту не отличимую от полноты оригинала.
Причуды абстракций