Комментарии 76
То есть изменить работающий и протестированный класс Rectangle?
Как же тогда
> новая функциональность вводится путем добавления нового кода, а не изменением старого, уже работающего?
Обычная путаница с этим прямоугольником.
Все потому, что забываете о главном принципе ООП — абстракции.
Если наши фигуры математические, то они иммутабельные. И да, квадрат это наследник прямоугольника. Без проблем.
Как только водятся методы set, то это уже не математические фигуры. Это что-то из реального мира.
И тогда "фигня в фирме прямоугольника, которая может менять размеры и пропорции" это не предок "фигни в форме квадрата, которая может менять размеры".
Для уточнения иерархии надо понять, что же у нас есть. Модель чего мы строим.
По сути весь SOLID это уточнение абстракции
А может квадрат наследник ромба без проблем?
Так что изменяемость ромба ломает наследование.
И тут интерфейсы лучше, потому что
public Square extends Square, Rhombus
Очень хорошее замечание, что неизменяемые прямоугольник и квадрат действительно являются родственными. Однако, для их изменения требуется объявить операции с ними. И именно в этот момент делает их разными в поведении. И запрещает наследование. Абстракция интерфейса по сути говорит, что у них есть одноименные действия, но никак не указывает, что действия будут хоть сколько-то одинаковыми.
Вот так бы сходу я бы не отвергал применение LSP и к интерфейсам. Точнее сходу в голову даже не приходит, когда его однозначно можно отвергнуть.
Если под интерфейсом понимать полное сохранение семантики, то например для квадрата вызов метода setSize(width, height) унаследованный от прямоугольника не может быть вызыван с отличающииися агрументами. Чуть ниже я привел пример, что у квадрата всегда есть вписанная окружность, и эта операция не всегда применима к прямогольнику.
Можно пойти дальше и в семантике операций прописывать, что они не всегда применимы и могут вернуть ошибку на этой операции. Но тогда мы отрываемся от всего математического аппарата, который рассматривает только возможные действия и формализует ошибки в виде запретов или ограничений.
Однако, для их изменения требуется объявить операции с ними. И именно в этот момент делает их разными в поведении. И запрещает наследование.
Нет никаких проблем.
public Square scale(Square, int) {}
public Rectangle scale(Rectangle, int) {}
public Rectangle scale(Rectangle, int, int) {}
Если мы меняем размер пропорционально, то квадрат останется квадратом. А иначе- всегда прямоугольник.
При этом да, интерфейсы позволяют абстрагироваться абстрактнее :)
Прямоугольник в такой модели может быть очень похожим на квадрат при определенных условиях и тогда у него может быть например метод isSquare() который ответит является ли фигура квадратом. У двух фигур может быть общий метод scale(factor). Но это будет общим свойством для фигур. Но факт остается фактом, что что у квадрата, что у прямоугольника могут быть свои специфичные сеттеры и даже геттеры. Например геттер получения вписанной окружности. (при этом я подразумеваю не просто совпадение имени но и полное сохранение семантики) не во всякий прямоугольник можно вписать окружность.
Но и проблема «наследует ли квадрат от прямоугольника»- надуманная.
Не столько надуманная, сколько показывающая, что в такой формулировке недостаточно данных для ответа и надо рассматривать операции над фигурами и их совместимость. Если все операции совместимы по семантике, то можно. Но в прикладной задаче программирования объекты имеют свойство обрастать все новыми методами и сохранить консистентность не получится на практике. Потому, либо смириться с неконсистентностью, либо пректировать так, чтобы не появлялось таких завязок.
И запрещает наследование
нет, просто надо переопределить наследуемую операцию
И да, квадрат это наследник прямоугольника.
Как по- по-вашему, красный прямоугольник наследник прямоугольника?
Нельзя отредактировать комментарий с мобильного. Какой же Хабр классный.
Другое дело, что математические фигуры цвета не имеют. Для построение правильной иерархии надо понимать задачу — абстракцию чего нам надо создать.
Я о том, что равенство сторон — это тоже просто значения свойства.
Продемонстрируйте пример этого "иногда".
Хотя бы из-за этого наследования — квадрат можно передать туда, где ждут ромб.
Нет.
>Все потому, что забываете о главном принципе ООП — абстракции
Вы сами напомнили и сами забыли ) Поэтому, по хорошему, мы делаем абстрактный класс Четырёхугольник и наследуем его. Тем самым, в иерархии квадрат и прямоугольник не будут наследоваться друг от друга.
Лучший код- его отсутствие.
Каждая лишняя строчка должна приносить пользу.
Где-то надо иметь интерфейсы и кассы «многоугольник», «четырёхугольник», «выпуклый четырёхугольник» «прямоугольник», «квадрат», «ромб», где-то хватит двух классов.
В классе Квадрат вам будет достаточно одного поля для хранения значения стороны. Я думаю понятно, что это уменьшит расход оперативной памяти и очень драгоценного кэша ЦПУ, если у вас будет очень много квадратов. Не говоря уже о том, что будет меньше путаницы с квадратами и прямоугольниками в коде.
>Лучший код- его отсутствие.
Нет.
>Каждая лишняя строчка должна приносить пользу.
Нет. Если вы пишите на высокоуровневом ЯП, то это задача компилятора в уменьшении количества кода для ЦПУ, а не ваша. Ваша задача, как промышленного программиста, делать код понятным для другого человека, подразумевая, что он будет менее образованным, чем вы сами.
Добрый день!
- Почему Абстрация важнее других принципов?
- Почему математические фигуры автоматом иммутабельны? Квадрат с изменяемой стороной перестаёт быть квадратом в математическом смысле?
Почему Абстрация важнее других принципов?
Потому что если посмотреть на ошибки проектирования- то неверная/протекающая абстракция встречается очень и очень часто.
Почему математические фигуры автоматом иммутабельны?
В математике даже нет понятия «присвоить переменной новое значение»
Если уж пишут, что x=5 то оно всегда равно 5, а не до какой-то точки.
Квадрат с изменяемой стороной перестаёт быть квадратом в математическом смысле?
На мой взгляд- да.
и Анжела Меркелева...
Так как класс Square находится в соотношении ISA с классом Rectangle, будет логично предположить, что тесты на Rectangle должны быть справедливы и для Square.
Если у вас одинаковые тесты для обоих наследников, то зачем вам нужны были разные классы?
Пример выглядит очень надуманным. Мы ввели новую сущность, но тесты для неё используем от другой сущности и поэтому всё сломается. Ну да, а вы чего ожидали?
Это не просто другая сущность — это наследник, подкласс. И тесты, которые справедливы для родителя должны быть справедливы и для наследника как раз из-за Принципа подстановки.
как правильно формировать иерархию наследования и ответим на философский вопрос о том, является ли квадрат прямоугольником.
Ну что, опять?
Вспомним принцип открытости-закрытости. Он говорит о том, что в хорошо спроектированных программах новая функциональность вводится путем добавления нового кода, а не изменением старого, уже работающего. И это есть основа написания поддерживаемого и переиспользуемого кода. Принцип подстановки Лисков (далее LSP) — это по сути гайдлайн того, как реализовать этот принцип при построении иерархии наследования классов в объектно-ориентированных языках
Open-Close это вовсе не только наследование, лучше уже про композицию рассказывать, в большинстве случаев полезнее будет.
парадигме ООП, которая, в частности, говорит нам, что классы должны моделировать объекты реального мира
А автор термина говорил(раз говорил, два говорил, тут писал, тут в комментариях писал), что объекты должны быть изолированны как биологические клетки, и общаться друг с другом через посылку сообщений, а про моделирование реального мира — не говорил.
Да и кому нужно реальный мир моделировать(соседа, кстати, надо или нет моделировать?) — не понятно.
Да и кому нужно реальный мир моделировать(соседа, кстати, надо или нет моделировать?) — не понятно.
Моделирование есть абстрагирование от неважных для задачи деталей. Так что если сосед в задаче важен, то нам надо его моделировать
В любом случае, относительно определения — важные для бизнеса процессы вполне себе моделируются в любой парадигме, и что есть код который реализует бизнес-требования и не моделирует процессы я не могу себе представить, хотя и термин моделирования не так уж однозначен.
А если пытаться спроецировать какие-то отношения из-реального мира на иерархии наследования(а-ля moderator extends user, или фигуры с геттерами, как выше пытаются) — то такого моделирования уж точно не нужно.
Для меня наследование — один способов моделирования отношений между сущностями предметной области и moderator extends user выглядит нормально на первый взгляд, если и то, и другое сущности предметной области.
Для меня наследование — один способов моделирования отношений между сущностями предметной области и moderator extends user выглядит нормально на первый взгляд, если и то, и другое сущности предметной области.
Я думаю если это и нормально где-то, должно быть много допущений, и сходу пример где это оправдано придумать не могу(ну, кроме случаев когда проект маленький и без разницы).
Во первых, если есть какой-то единый класс User в проекте — очень часто(из того что я видел/узнавал
Думаю понятно что при таком раскладе уже пора решать проблемы, а не множить наследованием.
Далее, сходу я могу набросать лишь пару контекстов, где может быть сущность «модератор» — секьюрити(права, доступы) и что-нибудь вроде юзер-профиля, который у модератора, например, будет особенный.
В первом случае речь уже о конфигах каких-то, а не сущностях, а во втором — проще в контексте профилей завести свойство «тип профиля» или что-то подобное.
Вобщем, мне интересно в каких случаях вы можете предложить такую иерархию, и почему она будет лучше чем вынесение общей логики отдельно.
Я пока что считаю, что использование наследования это исключительно техническая деталь реализации, которая не должна выбираться только ради какой-то схожести с реальным миром. И с технической точки зрения штука эта — весьма опасная.
Вот именно потому что с технической точки зрения она довольно опасна, для технических задач типа DRY она должна использоваться только в исключительных случаях. Для моделирования же она вполне подходящая, пока а) отражает отношение "является" в контексте предметной области и б) не возникает для этого отношения проблемы множественного наследования
Moderator extends User вполне может быть нормальным когда есть полное понимание, что модератор это реальный пользователь с дополнительными свойствами и особым поведением, не ограничивающееся просто правами. Например, у модератора может быть список забаненых и собственно метод banUser().
Open-Close это вовсе не только наследование, лучше уже про композицию рассказывать, в большинстве случаев полезнее будет.
Да, это вовсе не только наследование, но LSP показывает нам как его соблюсти именно в контексте наследования.
Да и кому нужно реальный мир моделировать(соседа, кстати, надо или нет моделировать?) — не понятно.
Пожалуй я слишком упрощённо сформулировал. Речь идёт о том, что мы оперируем абстракциями, причём не столько непосредственно объектов реального мира сколько понятий. У каждого понятия есть набор признаков. При написании ООП-кода мы выбираем те из них, которые являются наиболее значимыми в конкретной предметной области, то есть формируем абстракцию.
если для каждого объекта o1 типа S существует такой объект o2 типа T, что для всех программ P, определенных в терминах T, поведение программы P не изменяется, если вместо o2 подставить o1, то S — подтип T.
если S является подтипом T, тогда объекты типа T в программе могут быть замещены объектами типа S без каких-либо изменений желательных свойств этой программы
Функции, которые используют базовый тип, должны иметь возможность использовать подтипы базового типа, не зная об этом.
То есть во втором и третьем случае требуется только совместимость, в то время как первая формулировка, если её понимать максимально строго, фактически запрещает полиморфизм.
В то время, как в оригинале:
Let phi(x) be a property provable about objects x of type T. Then phi(y) should be true for objects y of type S where S is a subtype of T.вакуум, сферичнее некуда, и никаких конкретных указаний там нет. Под него больше всего подходит как раз 2-й вариант.
За пример с квадрато-прямоугольником — прямо мега, гига, супер респект. Невероятно визуально и интуитивно.
Rectangle extends Square
extends — перев. расширять
Square — прямоугольник с наложенными на него ограничениями (т.е. прямоугольника «шире»)
А треугольник это частный случай четырехугольника, а четырехугольник частный случай шестиугольника у которого 2 угла 180 градусов.
(С) Ваш К.О.
Я бы еще отметил, что наследование классов — впринципе очень опасная операция, с которой надо обращаться аккуратно. Когда вы наследуюте один класс от другого, то вы подразумеваете, что наследуете то, как там ваш родитель "работает под капотом". Иначе говоря вы наследуете не только семантику, но еще и реализацию этой семантики.
Выражать конструкции вида "квадрат это прямоугольник" путем наследования класса Square
от класса Rectangle
— опасно (это как раз то, что приводит к тому, что класс Square
в реализации в статье работает не оптимально и требует в два раза больше памяти чем нужно).
Правильнее определить два интерфейса (я думаю не стоит упоминать, что интерфейсы еще и позволяют Square
унаследовать дополнительно от Rhombus
...):
interface Rectangle {
int getHeight();
int getWidth();
int perimeter();
}
interface Square extends Rectangle {
int getSideLength();
default int getHeight() { return getSideLength(); }
default int getWidth() { return getSideLength(); }
}
И после этого писать реализации RectangleIml
и SquareImpl
под каждый. Причем на практике допустимо реализовать SquareImpl
путем наследования от RectangleIml
, это обычный инженерный trade-off между "написать поменьше кода" и "покушать меньше памяти". Просто академически это неправильно.
Ну а про мутабельность Rectangle
и Square
сверху умные люди уже написали. В данном случае иммьютабельность — это не просто поклон моде, а вынужденная необходимость, чтобы Rectangle
и Square
оставались прямоугольником и квадратом в математическом определении соответственно. Если есть желание по существующему объекту интерфейса Rectangle
получить другой Rectangle
, с измененной высотой, то это можно делать имьютабельно путем добавления метода в интерфейс Rectangle
:
/**
* Creates new Rectangle which inherits width from {@code this} Rectangle but has {@code newWidth}
*/
Rectangle rectWithWidth(int newWidth);
Известно, что наследование реализует отношение «является» (ISA).
Я не думаю, что много программистов на практике придерживаются этого. В основном это вопрос компоновки кода, а не взаимосвзяей. Т.е. с помощью ООП пытаются реализовать более древнюю мантру "не повторяй себя".
Я занимаюсь разработкой на Java в Tinkoff.ru
И не нашлось примеров из реальной работы?
Если нет, то либо сами не применяете принцип либо негде применять в реальности:)
Как же у меня полыхает, когда из SOLID делают заповеди. Заповедь L гласит: "И не унаследует же никто квадрат от прямоугольника, ибо не будет тому прощения отныне и во веки веков".
- Что будет нарушено, если абстракции фигур не будут требовать операций нахождения периметра и площади и прочих нарушающих L в SOLID?
- Может всё-таки воздержаться от утверждений "A нельзя моделировать как наследник B"?
- Может быть всё-таки всё дело в конкретной выбранной абстрактной модели под конректную задачу?
На геттеры/сеттеры давно пора начать крестовый(++) поход! OOP VULT!
Геттеры норм для сущностей, пока сущности не выходят за пределы слоя бизнес-логики
На геттеры/сеттеры давно пора начать крестовый(++) поход! OOP VULT!
А в самой лишь войне против них не много смысла. Если воинственно отстаивать их «ненужность», вы будете либо вызывать презрение и недоверие, либо просто один карго культ замените другим.
Нужно развивать понимание, а не сеять смуту.
Итак, квадрат — это прямугольник, у которого стороны равны, тем не менее, код квадрата:
а/ вместо выдачи ошибки почему-то позволяет задавать разные длины сторон и
б/ тайно «под капотом» их меняет, т.е. set несимметричен с get
Тест на непонятных основаниях считает, что:
а/ у прямоугольника set-ы детерминированы (не меняют состояние других элементов) и
б/ не отлавливает ошибку задания длины
А если задать нулевую или отрицательную? А если завтра наледником будет не просто Square, а Square4x4?
Мораль: функция, принимающая наследников базового класса, должна следовать контракту базового класса, а не придумывать свой.
объект класса Square — это определенно не объект класса Rectangle
да ладно
public class Rectangle
{
public double Width { get; set; }
public double Height { get; set; }
public double Perimeter => 2 * (Width + Height);
}
public class Square : Rectangle
{
public new double Width
{
get => base.Width;
set => base.Width = base.Height = value;
}
public new double Height
{
get => base.Height;
set => base.Width = base.Height = value;
}
public Square(double side) => Width = Height = side;
}
public class Program
{
public static void Main()
{
Square s = new(5);
Console.WriteLine(s.Height);
Console.WriteLine(s.Perimeter);
Console.ReadLine();
}
}
Точнее так (с учетом всего прочитанного тут) - только вот реализацию периметра не удается унаследовать о прямоугольника, а может и не нужно
public interface IQuadrilateral
{
public double Perimetr();
}
public class Quadrilateral:IQuadrilateral
{
private double[] _sides;
public Quadrilateral() { _sides = new double[4]; }
public double Perimetr() => _sides.Sum();
}
public interface IRombus : IQuadrilateral
{
}
public interface IParalellipiped : IQuadrilateral
{
}
public class Paralellipiped: IParalellipiped
{
private double[] _sides;
public Paralellipiped() { _sides = new double[4]; }
public double Perimetr() => _sides.Sum();
}
public class Rombus:IRombus
{
private double _size;
private double _angle;
public double Perimetr() => 4 * _size;
public Rombus(double size, double angle)
{
_size = size;
_angle = angle;
}
}
public interface IRectangle: IParalellipiped
{
public double GetHeight();
public double GetWidth();
}
public class Rectangle:IRectangle
{
private double _height;
private double _width;
public Rectangle(double height, double width)
{
_height = height; _width = width;
}
public double GetHeight() => _height;
public double GetWidth() => _width;
public double Perimetr() => 2 * (_height + _width);
}
public class Square: IRectangle,IRombus
{
private double _side;
public Square(double side)
{ _side = side; }
public double GetHeight() { return _side; }
public double GetWidth() { return _side; }
public double Perimetr() { return 4*_side; }
}
public class Program
{
public static void Main()
{
Square s = new(5);
Console.WriteLine(s.GetHeight());
Console.WriteLine(s.Perimetr());
Console.ReadLine();
}
}
через расширения удобней
public class Diamond
{
protected double _side;
protected double _diagonal;
public Diamond(double side, double diagonal) {_side = side; _diagonal = diagonal;}
public double Side { get => _side; set => Side = value; }
public double Diagonal { get => _diagonal; set => Diagonal = value; }
public void Display() => Console.WriteLine($"Side: {_side}");
public static implicit operator Diamond(Square square) => new Diamond(square.Side, square.Side*Math.Sqrt(2));
}
public class Rectangle
{
protected double _width;
protected double _height;
public Rectangle(double height, double width) { _height = height; _width = width; }
public double Width { get => _width; set => _width = value; }
public void Display() => Console.WriteLine($"Width: {_width}, Height: {_height}");
public static implicit operator Rectangle(Square square) => new Rectangle(square.Side, square.Side);
}
public class Square
{
protected double side;
public Square(double side) => this.side = side;
public double Side { get => side; set => side = value; }
public void Display() => Console.WriteLine($"Side: {side}");
}
class Program
{
static void Main(string[] args)
{
Square square = new Square(5);
square.Display();
Console.WriteLine();
Rectangle rectangle = square;
rectangle.Display();
Console.ReadLine();
}
}
На всякий случай приведу реализацию на PHP 8.3.
Мне кажется такой вариант не будет нарушать L-принцип?
Хотя есть и некоторые сомнения.
Как вы считаете?
<?php
class Rectangle
{
public function __construct(private int $width, private int $height)
{
}
public function getWidth(): int
{
return $this->width;
}
public function getHeight(): int
{
return $this->height;
}
public function getPerimeter(): float|int
{
return 2 * $this->height + 2 * $this->width;
}
}
class Square extends Rectangle
{
public function __construct(private int $side)
{
parent::__construct($side, $side);
}
}
class GeometryTest extends TestCase
{
public function testRectangleCreation(): void
{
$rectangle = new Rectangle(10, 20);
$this->assertInstanceOf(Rectangle::class, $rectangle);
$this->assertEquals(10, $rectangle->getWidth());
$this->assertEquals(20, $rectangle->getHeight());
$this->assertEquals(60, $rectangle->getPerimeter());
}
public function testSquareCreation(): void
{
$square = new Square(12);
$this->assertInstanceOf(Square::class, $square);
$this->assertEquals(12, $square->getWidth());
$this->assertEquals(12, $square->getHeight());
$this->assertEquals(48, $square->getPerimeter());
}
public function testSquareIsRectangle(): void
{
$square = new Square(12);
$this->assertInstanceOf(Rectangle::class, $square);
}
}
Тесты проходит успешно.$ ./phpunit
PHPUnit 9.6.19 by Sebastian Bergmann and contributors.
... 3 / 3 (100%)
Time: 00:00.004, Memory: 6.00 MB
OK (3 tests, 9 assertions)
Функции, использующие указатели или ссылки на базовые классы, должны иметь возможность использовать объекты классов-наследников, не зная об этом
Мне кажется ваш тест ни как это не учитывает. Вы тестируете внутреннюю реализацию, проверяете что аргумент конструктора записался в длину и ширину правильно, но зачем такие тесты? Зачем тесты которые тестируют геттеры или assertInstanceOf ?
Чтобы проверить LSP вам нужна некая 3ая функция, например calcPerimeter(Rectangle $rect): float|int; а в тесте нужно запустить ее с Rectangle и Square, и ничего не должно сломаться.
Принцип подстановки Лисков