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

Комментарии 32

Наследование — один из столпов ООП.

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

Для переиспользования кода нет никаких аргументов в пользу наследования, кроме того что из-за хайпа и моды на Java/C++ стиль «ООП» новички не интересуются альтернативами и упускают из вида объективные ценности и концепты при проектировании классов. Хотя бы те же Interface Segregation Principle(и, конечно, LSP), и стоящие за ними концепты coupling/cohesion и влияние всего этого на вероятность каскадных изменений и читаемость кода.

Лучше выделить более общий подтип для обеих фигур.


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


Подробнее здесь.

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

Это на самом деле частая проблема обучающих статей про ООП. Когда пытаются рассказывать, что раз Coffe (кофе) — это подтип Drink (Напитка), то мы должны или не должны наследовать одно от другого. Но в жизни никто НЕ моделирует реальность as-is. И это классическая и грубая ошибка новичка. Если ты делаешь класс для расчета нагрузки на балки, чаще всего тебе не надо моделировать саму балку, а просто запрограммировать функцию расчета в зависимости входных параметров.

Если говорить о предметной области, то это DDD и класс обязан быть построен на свойствах объекта предметной области. И если предметная область говорит нам о том, что есть Напиток и Кофе, и наша предметная область в конкретном ограниченном контексте говорит, что Кофе это частный случай Напитка, то в коде мы обязаны отразить это наследование.

Вообще говоря то, что код не удовлетворяет DDD НЕ является реальной проблемой. Реальной проблемой будет — если его будет тяжело читать, поддерживать, рефакторить и т.д и т.п.

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

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

Я бы всё таки добавил перед словом «облегчает» слово «часто» или даже слово «иногда». :)

А я бы не добавлял)))

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

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

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

Правильно! Поэтому, как рекомендовали в умной книге (название, конечно, не помню) — не квадрат наследуем от прямоугольника, а наоборот, прямоугольник от квадрата.

Согласно этой рекомендации, надо забыть, что по математическому определению квадрат является подвидом прямоугольника. А обратить внимание на то, что прямоугольник «во многом ведёт себя как квадрат, только помимо ширины имеет ещё длину, а площадь у него вычисляется вот так: ...»
Все так! Попытки проектирования от предметной области, а не от проблемы, которую мы решаем — приводят к таким вот рассуждениям.

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

не квадрат наследуем от прямоугольника, а наоборот, прямоугольник от квадрата
И получаем такое же нарушение принципа подстановки Барбары Лисков, как и в первом способе. Потому что передача объекта Rectangle вместо Square может дать другой результат работы. Квадрат и прямоугольник нельзя наследовать друг от друга, не нарушая LSP.

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

Справедливости ради, есть некоторые правила, которые с большой вероятностью не дадут нарушить принцип подстановки. Можно себя максимально обезопасить, если запретить все опасные конструкции. Например, для C++ об этом написано у Олега. Но в целом такие правила превращает классы не в совсем классы в классическом понимании.

То что написано в блоге Олега очень похоже на некоторый вариант анемичной модели.
Хотя это вроде как почти прямая трансляция Haskell -> C++

Никогда не понимал откуда эти проблемы с квадратом и прямоугольником…
Базовый постулат: каждый квадрат является прямоугольником (но не каждый прямоугольник — квадратом). Следовательно у класса Квадрат (который является потомком класса Прямоугольник) должен быть переопределён метод контроля длин сторон и сеттеры для них. Никто же не удивляется что нельзя создать прямоугольник с нулевой или отрицательной длинами сторон? Так же и квадрат — он должен контролировать единообразность длин. А уж что делать при расхождении (генерировать исключение или приводить вторую сторону в соответствии с задаваемой) — вопрос договорённостей (и, в известной степени, деталей реализации интерфейсов класса Прямоугольник).
Базовый постулат: каждый квадрат является прямоугольником...

… но только с точки зрения математики. Квадрат в понимании математика и квадрат в понимании программиста — разные сущности.


Следовательно у класса Квадрат (который является потомком класса Прямоугольник) должен быть переопределён метод контроля длин сторон и сеттеры для них.

Это нарушит LSP. Удачи в использовании такого класса в дальнейшем — она точно понадобится.

Квадрат в понимании математика и квадрат в понимании программиста — разные сущности.

А можно попросить раскрыть понимание квадрата для программиста?

Это нарушит LSP. Удачи в использовании такого класса в дальнейшем — она точно понадобится.

Как конкретно он их нарушит? С точки зрения поведения экземпляры класса Квадрат будут совершенно аналогичны экземплярам класса Прямоугольник.
А можно попросить раскрыть понимание квадрата для программиста?

У "математического" квадрата нельзя изменить размер так, чтобы они при этом остался тем же самым квадратом. У "ООП-квадрата" — можно.


Как конкретно он их нарушит? С точки зрения поведения экземпляры класса Квадрат будут совершенно аналогичны экземплярам класса Прямоугольник.

А обсуждаемый пост тут для кого был написан? У прямоугольника можно менять длину и ширину независимо, у квадрата — нет. Это и есть разное поведение.

У «математического» квадрата нельзя изменить размер так, чтобы они при этом остался тем же самым квадратом. У «ООП-квадрата» — можно.

И у ООП-квадрата нельзя. Как?

А обсуждаемый пост тут для кого был написан? У прямоугольника можно менять длину и ширину независимо, у квадрата — нет. Это и есть разное поведение.


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

Смотрите:


var square = new Square(5);
var old = square;
square.Size = 10;
Console.WriteLine(old == square); // true

Просто у него нет понятий «длина» и «ширина», а есть понятие «длина стороны»

Так именно в этом и проблема: нет у него двух разных понятий "длина" и "ширина", а потому их нельзя менять независимо друг от друга. А вот у прямоугольника — можно.

Смотрите:

Минуточку! Я правильно понимаю что в вашем понимании отличие ООП-фигуры (что квадрата, что прямоугольника, что окружности) от математического состоит в том, что у них можно изменить размер? Ну, потому что приведённый вами фрагмент кода демонстрирует несколько отличную от исходной («изменить размер так, чтобы он при этом остался тем же самым квадратом») идею, а именно «у ООП квадрата можно изменить размер и он, при этом, по прежнему останется квадратом». А если добавить перед последней строчкой что-нибудь вроде
square.Color = Red;
результат последней будет такой же (true)?

Так именно в этом и проблема: нет у него двух разных понятий «длина» и «ширина», а потому их нельзя менять независимо друг от друга. А вот у прямоугольника — можно.

Это кто вам такое сказал? С чего вообще вы берёте на себя смелость утверждать что вы можете гарантировать то или иное поведение класса (при условии что вы не видите и не определяете его реализацию)? Откуда убеждённость в том что длины сторон непременно независимы?
Давайте на секунду представим что у класса «Прямоугольник» есть потомок «Прямоугольник с соотношением сторон 1:2» (или вроде того). Вы будете утверждать что класс Прямоугольник никак не может являться его предком? Если да — на каком основании?
Ну, потому что приведённый вами фрагмент кода демонстрирует несколько отличную от исходной («изменить размер так, чтобы он при этом остался тем же самым квадратом») идею

А в чём отличие? В том-то и дело, что изменяемый (мутабельный) объект после изменения остаётся именно тем же самым объектом!


Я же в последней строчке проверяю его идентичность (ну или эквивалентность если оператор перегружен), а вовсе не тип.


А если добавить перед последней строчкой что-нибудь вроде square.Color = Red; результат последней будет такой же (true)?

Разумеется. Нет разницы что мы делаем с объектом, пока мы явно не заменим его на другой — это будет тот же самый объект. В этом и отличие ООП от математики.


Это кто вам такое сказал? С чего вообще вы берёте на себя смелость утверждать что вы можете гарантировать то или иное поведение класса (при условии что вы не видите и не определяете его реализацию)? Откуда убеждённость в том что длины сторон непременно независимы?

Принцип наименьшего удивления.


Давайте на секунду представим что у класса «Прямоугольник» есть потомок «Прямоугольник с соотношением сторон 1:2» (или вроде того).

Такой потомок нарушает LSP точно так же, как это делает потомок-квадрат.

Я же в последней строчке проверяю его идентичность (ну или эквивалентность если оператор перегружен), а вовсе не тип.

Я, признаться, не силён в синтаксисе C# (или на чём этот код написан?). Но в других языках подобные присваивания (old = square;) порождают новый экземпляр (который дальше живёт своей жизнью). Поэтому я и пишу вам про разницу между эквивалентностью и бытием (есть ОГРОМНАЯ разница между понятиями «тот же» и «такой же»).

Принцип наименьшего удивления.

Эвона… То есть вы ожидаете что объект будет себя вести так, как хочется вам, притом что вам мало что известно о его внутренней структуре и реализации? Я всё пытаюсь намекнуть на то, что строчка
assert rect.get_area() == 200
это грубое нарушение принципа инкапсуляции.

Откуда вообще убеждённость в том, что будет два разных метода для задания ширины и высоты, а не один для задания двух сторон сразу (и, при этом, виртуальный), например? Который у Квадрата будет вызывать ошибку при вызове с разными значениями в параметрах…

Такой потомок нарушает LSP точно так же, как это делает потомок-квадрат.

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

Но в других языках подобные присваивания (old = square;) порождают новый экземпляр (который дальше живёт своей жизнью).

Я не знаю какие другие языки имеете ввиду вы, но в большинстве известных мне ООП-языках при синтаксисе «old = square;» и сложном типе вы получаете не копию, а ссылку/указатель на объект.

Замените все вызовы Прямоугольник. на Квадрат. и всё будет работать ровно так же (если вы правильно сделали реализации).

Так о том и идёт речь. Как правильно делать реализацию наследования и когда что можно наследовать, а что нельзя. И в конкретном случае когда у вашего прямоугольника есть методы setWidth и setHeight, то наследовать от него квадрат не самая хорошая идея потому что у квадрата функция setWidth будет менять сразу ширину и высоту что кaк минимум не соответствует её названию.

Но это не означает что квадрат никогда в принципе нельзя наследовать от прямоугольника.

вы получаете не копию, а ссылку/указатель на объект

На самом деле в данном случае это не имеет значения :) Потому что исходный пример был ложной демонстрацией — из изменения значения какого-либо атрибута объекта не следует что сам объект стал другим.

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

Безусловно идея не самая лучшая (скорее всего Прямоугольник и Квадрат вообще потомки общего предка Многоугольник), но речь не об «удачно-неудачно», а о «нарушает LSP/не нарушает LSP». И как по мне — при корректной реализации — не нарушает.

На самом деле в данном случае это не имеет значения :) Потому что исходный пример был ложной демонстрацией — из изменения значения какого-либо атрибута объекта не следует что сам объект стал другим.

Так я именно об этом вам и говорю! Математический квадрат становится другим из-за изменения размера стороны. А в ООП — не становится.

Откуда вообще убеждённость в том, что будет два разных метода для задания ширины и высоты, а не один для задания двух сторон сразу (и, при этом, виртуальный), например? Который у Квадрата будет вызывать ошибку при вызове с разными значениями в параметрах…

Потому что я такой класс придумал.


Существует бесконечное число способов сделать класс Прямоугольник с помощью ООП, и большинство их них не позволяют унаследовать от них Квадрат.


При этом они вовсе не являются неправильными — напротив, они замечательно подходят в тех случаях, в которых от них не требуется быть базовым классом для квадрата.

Может так понятнее будут проблемы: каждый квадрат ещё является и ромбом. Квадраты — пересечение множеств прямоугольников и ромбов.

Тем хуже для ромба! :)
Кстати ситуация с ромбом отлично демонстрирует ущербность концепции множественного наследования.

Математика не работает с состоянием, если состояние квадрата изменилось, значит задана другая фигура. Любая динамика в математике раскладывается на последовательность отдельных состояний, существующих как бы одновременно. В ООП же у объекта есть только одно состояние — текущее.


В существующих языках, по крайней мере императивных с наследованием в стиле C++, нельзя правильно задать взаимоотношения квадрата и прямоугольника с учетом того, что у них есть состояние и поведение. Для этого нужна возможность, чтобы одна и та же переменная в разных контекстах могла иметь разный тип, чтобы можно было передать переменную одного типа в функцию, принимающую другой тип, без создания нового объекта. То есть runtime-типы.


Что-то типа такого:


class Rectangle {
    int width;
    int height;
    void setWidth(int) { ... }
    void setHeight(int) { ... }
}

class Square variant of Rectangle {
    bool __match() {  return (this.width == this.height);  }
}

void f(Square s) { ... }

r = new Rectangle();

r.setWidth(100);
r.setHeight(100);
f((Square)r);  // no error

r.setWidth(200);
r.setHeight(100);
f((Square)r);  // runtime error
Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории