Изучение SOLID на втором курсе через считанное число лекций (а значит недель) после того, как был изучен обычный полиморфизм -- это, на мой взгляд, мягко говоря, преждевременно. С другой стороны, в курсе по ООП, что обзорном, что, тем более, детальном, нельзя эту тему обойти стороной, потому что она -- предмет не только опыта, но и кругозора, и на собеседованиях по ООП она может стать если не предметом, то поводом для обсуждения. Не расстраивайтесь, если здесь и сейчас обсуждаемые в этой главе понятия и принципы покажутся вам слишком сложными или непонятными. Любое обсуждение проектирования (а SOLID, в отличие от паттернов -- это не конкретные приёмы, а именно принципы) требует наличия опыта, набитых шишек, успешных и неуспешных решений. Всего этого у вас пока нет. Поэтому эта глава должна загрузить в ваш мозг новые понятия и "пищу для ума", которая будет перевариваться постепенно и всплывать по мере накопления вами своего программистского опыта в области ООП.
SOLID — это английская аббревиатура для пяти известных принципов, применяемых при разработке объектно-ориентированных программ, описанных Р. Мартином в статьях и книгах, например, в [Роберт Мартин. Принципы, паттерны и методики гибкой разработки на языке
C#. Символ-Плюс, 2011] (конечно же, совершенно случайно так совпало, что первые буквы, будучи соединёнными, дали значащее слово английского языка):
Принцип единственной ответственности (Single responsibility principle)
Принцип открытости/закрытости (Open-closed principle)
Принцип подстановки Лисков (Liskov substitution principle)
Принцип разделения интерфейса (Interface segregation principle)
Принцип инверсии зависимостей (Dependency inversion principle)
Как и с любыми другими принципами, всегда найдутся евангелисты, которые поднимут их на флаг или превратят в кувалду, и соблюдение их будут считать самоцелью, а не средством. Цель разработки и внедрения этих принципов -- борьба с излишней сложностью при разработке ПО. У вас может быть сложная предметная область, и ей будет соответствовать сложная структура программы (так называемая "естественная" сложность). Некорректное, неграмотное, непрофессиональное использование ООП приводит к тому, что сложность программы начинает сильно превышать сложность той проблемы, которую программа призвана решать (так называемая "привнесённая" сложность). В своей статье я писал о том, что это происходит из-за того, что в программировании очень легко повышать сложность и очень сложно понижать сложность. Так вот, использование принципов хорошо тогда, когда оно позволяет уменьшать сложность кода, и плохо, когда наоборот. Это главный, на мой взгляд, критерий. Ну и так же, как с паттернами, сами по себе принципы SOLID часто являются по существу проявлением здравого смысла в разработке ПО, но при этом могут использоваться для вежливой коммуникации: "Ты тут, похоже, нарушаешь SRP" -- это краткая форма долгого и в этом случае ненужного объяснения, что в этом коде не так.
Как и с любыми другими принципами, с SOLID возможны две проблемы, ошибки первого и второго рода. Любой из этих принципов можно нарушить, а можно настолько рьяно, буквально и чрезмерно его применять, что качество кода это будет только ухудшать. Когда говорят, что кто-то нарушает какой-то из принципов SOLID, стоит выяснить, ошибка первого или второго рода имеется в виду. Опыт разработки даст вам ответственность применять эти принципы так и там, где они нужны, и смелость отказываться от них там, где они не нужны. Но для этого как минимум нужно знать, про что они есть.
Есть мнение, что любую идею лучше изучать в первоисточнике, и в этом случае за детальным описанием вы пойдёте к "Дядюшке Бобу" в [Роберт Мартин. Принципы, паттерны и методики гибкой разработки на языке C#. Символ-Плюс, 2011]. Однако можно сказать, что сам первоисточник написан как раз евангелистом (читать можно, но осторожно), и я бы рекомендовал знакомиться с идеей через более критическое осмысление, например, цикл статей Сергея Теплякова @SergeyT на тему SOLID или его книгу [Паттерны проектирования на платформе .NET. Питер, 2015] и не только. В книге есть и паттерны тоже, но рекомендовать её можно, на мой взгляд, уже для продвинутого изучения.
Принцип единственной ответственности в разных формулировках утверждает, что у любого класса должна быть одна единственная, чётко определённая зона ответственности, область применения, решаемая задача. Класс должен заниматься решением одной целостной задачи. Первоисточник ещё утверждает о наличии "одной и только одной причины для внесения изменений в класс", но мне это объяснение, если честно, кажется неубедительным. Этот принцип скорее всего нарушается тогда, когда в одном классе смешиваются явно разнородные, разноуровневые с точки зрения здравого смысла обязанности, которые часто можно в описании противопоставлять друг другу (то, что про вы можете сказать: "Х -- это одно, а Y -- это совсем другое"). Например, хранение информации -- и отображение информации, ввод данных -- и вывод данных, подготовка данных -- расчёт с использованием этих данных, низкоуровневой -- и высокоуровневой код.
Почему это становится проблемой? Да потому что это получаются не очень зависимые между собой части, собранные в одном классе, каждая из которых может использоваться где-то отдельно сама по себе -- или в комбинации с другими классами. Я бы предложил вот такой критерий: если какую-то часть класса вы можете вынести в отдельный класс и использовать отдельно саму по себе или в комбинации с другими классами -- то её нужно вынести в отдельный класс.
Принцип открытости/закрытости утверждает, что программные сущности (классы, модули, функции и другие) должны быть открытыми для расширения, но закрытыми для модификации. В чём проблема модификации класса? Очевидно -- в том, что класс может уже использоваться в каких-то очень далёких от вас частях кода, о которых вы можете ничего не знать. Меняя класс вы легко можете сломать работу кода, который его использует.
Что тогда делать? Мы знаем, как писать ООП-код, который использует уже существующий код -- например, используя композицию или наследование. Есть какой-то класс, который почти такой, какой нужен? Не меняйте его, говорит нам этот принцип -- лучше унаследуйтесь и добавьте новую функциональность в потомках.
Означает ли это, что даже при обнаружении какой-то ошибки в классе вы не должны его изменять? Конечно нет! Ошибки и исправления могут и должны вноситься в существующий класс или модуль, даже если кто-то там уже использует эти ошибки и исправление их сломает его поведение.
Принцип открытости-закрытости утверждает, что разрабатывать хорошую, годную программную систему -- одновременно расширяемую, в которой появляется новая функциональность, и устойчивую, в которой добавление новой функциональности не ломает тех, кто использует только старую -- можно путём выбора такой архитектуры, когда новая функциональность появляется дописыванием новых классов, а не изменением существующих. Ну и просто в соответствии с принципом инкапсуляции, вы должны иметь возможность безопасно (без неожиданностей для тех, кто класс использует) изменить внутреннюю реализацию класса, не меняя его интерфейс, поэтому под принципом открытости-закрытости иногда понимают ещё и открытость класса к использованию через его интерфейс, и закрытость внутренней реализации класса.
На самом деле, мы уже много где видели следование этому принципу в предыдущих главах. Например, в паттерне Декоратор мы не изменяли существующий класс, когда хотели, чтобы он сам научился рисовать вокруг себя рамку, а дописывали новый класс-обёртку Decorator, который в итоге позволял добиться того же самого, но оставив исходный класс Point неизменным. В паттерне Команда для того, чтобы научить программу выполнять новое действие с объектом (к тому же отменяемое!), не надо было изменять сам объект -- нужно было дописывать новый класс для новой команды, а основной цикл в программе при этом даже не меняется. Наконец, в паттерне Посетитель мы вообще делаем как будто невозможное -- вместо того, чтобы добавить новый виртуальный метод в базовый класс иерархии (сложно представить себе в ООП более "опасное" действие), добавляем нового обработчика, который по факту приводит к тому же самому результату. Развитие кода путём дописывания нового кода, а не изменения существующего -- вот, на мой взгляд, главная идея этого принципа.
Принцип подстановки Лисков в разных, более и менее формальных формулировках утверждает, что вместо любого класса можно подсунуть любого из его потомков, и при этом ничего не должно сломаться. Это не значит, что ни в коем случае нельзя наследоваться и изменять при наследовании поведение существующего класса. Это значит только, что бывает такое наследование, которое нарушает принцип подстановки Лисков, а бывает такое, которое не нарушает.
А что вообще означает в предыдущем абзаце "ничего не должно сломаться"? Очевидно, что речь идёт о чём-то, что с объектом делают: обращаются к свойствам, вызывают методы -- то есть, речь идёт о каком-то контракте того, что можно делать с объектом. И принцип подстановки Барбары Лисков утверждает: контракт класса-потомка не должен противоречить контракту класса-предка. Если что-то умел предок, то это же должен уметь и потомок (и возможно что-то ещё своё, конечно!). Всё, что требуется потомку, обязательно должно было требоваться и от предка (и возможно что-то ещё). Обратите внимание, в двух последних предложениях -- предок и потомок находятся в разных частях предложения.
Сама проблема, для которой формулировался принцип подстановки Лисков возникает из-за того, как по-разному мы можем понимать и для каких разных целей мы можем использовать такой совершенно конкретный механизм, как наследование одного класса от другого.
С одной стороны, техническая реализация такова, что при наследовании класса Cat от класса Animal, класс Cat просто получает все свойства и методы класса Animal и право записываться в переменные, предназначенные для хранения объектов класса Animal. С другой стороны, вот это "право записываться в переменные, предназначенные для хранения объектов класса X" связано со совсем с другим: с пониманием наследования как отношения "является частным случаем". Мы утверждаем, что потомок Cat имеет право записываться в переменные, предназначенные для хранения объектов класса Animal потому, что Cat есть частный случай Animal, то есть Cat в то же самое время есть и Animal тоже.
И два этих понимания могут входить в противоречие друг с другом, как раз и нарушая принцип подстановки Лисков. Классическим примером здесь является так называемая "проблема круга-эллипса". Circle должен быть потомком Ellipse, или наоборот? С одной стороны, математика утверждает, что Circle является частным случаем Ellipse, потому что любой круг -- это эллипс, но не любой эллипс -- это круг. Тогда, получается, следуя логике классов Animal-Cat, нужно Circle сделать потомком Ellipse. Но тогда получается, что у круга будут свойства и методы эллипса, такие, как RadiusX, RadiusY, StretchX, StretchY, которые для круга не имеют смысла и не могут быть выполнены.
С другой стороны, если мы выберем Ellipse потомком Circle, реализуем у Circle свойство RadiusX, а Ellipse будет его наследовать и добавлять своё свойство RadiusY, то вроде бы лишних свойств ни у кого не будет, но тогда получится, что эллипс есть частный случай круга, и тот код, который использовал круг (например, рисовал его внутри квадрата и рассчитывал, что он там уместится), при использовании "частного случая круга" вдруг неожиданно обнаружит, что этот "круг" больше не умещается в квадрате, хотя его радиус всё ещё равен половине стороны квадрата.
"Проблема круга-эллипса" показывает, что некоторые взаимосвязи вида "является частным случаем" между классами просто нельзя описать прямым наследованием, не нарушив принцип подстановки Лисков. В данном случае, если мы хотим этому принципу следовать, мы должны оба класса, и Ellipse, и Circle унаследовать от какого-то общего предка, а не друг от друга. И дублировать в них общий код типа RadiusX, спросите вы?
Принцип разделения интерфейса, если его объяснять совсем на пальцах, применяет концепцию принципа единственной ответственности к интерфейсам, причём не к тем, кто эти интерфейсы реализует (это классы, реализующие интерфейсы, и за ними "бдит" принцип единственной ответственности), а к тем, кто эти интерфейсы использует. Формулировка принципа может быть примерно такая: код не должен зависеть от методов, которые он не использует или не должен использовать. Если мы работаем с каким-то классом через какой-то интерфейс, то в этом интерфейсе не должны быть методы, которые мы не используем или не должны использовать. Если вдруг такое случается, то такой интерфейс должен быть разделён на более мелкие части и мы должны работать с классом через одну из этих частей.
class IShape {
public:
void move() = 0;
void draw() = 0;
void save(FILE *f) = 0;
void load(FILE *f) = 0;
}
void saveToFile(string filename, IShape *shape) {
// ...
shape->draw();
shape->move();
// ...
}
Вот тут у нас метод saveToFile() берёт и вместо сохранения фигуры рисует её или двигает. Почему он так делает? Да потому, что может! Имеет ли место тут быть нарушение принципа единственной ответственности? Нет, этот принцип применим к классу, а мы тут класса не видим вообще. Возможно, когда у нас появится класс CShape, реализующий класс IShape, у нас появится повод к нему "прикопаться" на предмет того, а не слишком ли ты много на себя берёшь, и рисоваться, и сохраняться, и передвигаться -- но пока такого класса нет. А нарушение принципа разделения интерфейса уже есть. Как его исправить, чтобы функция saveToFile() видела только ту часть объекта, которая ответственна за сериализацию? Да просто разделить интерфейс:
class IShape {
public:
void move() = 0;
}
class IViewItem {
public:
void draw() = 0;
}
class ISerializeable {
public:
void save(FILE *f) = 0;
void load(FILE *f) = 0;
}
void saveToFile(string filename, ISerializeable *shape) {
// ...
shape->draw(); // error
shape->move(); // error
// ...
}
Обратите внимание, что в функцию saveToFile() вполне может передаваться всё тот же самый объект, но функции видна только та его часть, та его сторона, те его методы, которые ей необходимы для выполнения своей задачи.
Принцип инверсии зависимостей традиционно считается самым сложным и непонятным студентами, изучающими ООП. Я бы даже сказал, что причиной этого является то, что в отличие от всех остальных принципов SOLID, этот принцип не основывается на таком же очевидном здравом смысле. Ещё проблема чаще всего в том, что не всегда при объяснении этого принципа делается акцент на его главной идее (очень спорной, как по мне). Поэтому даже рисуя картинку этого принципа на экзамене можно ошибиться, потому что мы привыкли, что схему со стрелочками можно покорёжить, но пока всё указывает туда же, куда надо, схема со стрелочками считается правильной.
Вы видите разницу между этими двумя схемами? Те же самые стрелочки соединяют те же самые квадратики, но одна картинка верная, а другая нет. При этом, как по мне, так они обе не понятные, потому что догадаться, почему класс Mechanism реализует не интерфейс MechanismInterface, а интерфейс PolicyInterface (картинка в 99% случаев воспринимается именно так), может, наверное, только сам Роберт Мартин Фаулер!
Это всё, конечно, заговор, цель которого -- помешать нам понять, что именно имел в виду "Дядюшка Боб" в его книге, но чтобы его обойти, надо читать первоисточник. Согласно первоисточнику, принцип инверсии зависимостей состоит из двух частей, и со второй частью, как правило, проблем нет.
Вторая часть звучит так: "Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций". Если под абстракциями понимать абстрактные классы и интерфейсы, а под деталями -- те классы, которые их реализуют, то смысл этого принципа -- избежать ситуации, когда интерфейсы зависят от какого-нибудь конкретного класса. В более сильной форме это утверждение даже требует, чтобы везде, где это только возможно (но не там, где это невозможно) нужно стараться работать с объектами через интерфейсы:
class ISection {
public:
CPoint get_middle() = 0;
// IPoint get_middle() = 0;
}
Интерфейс отрезка, например, может позволять возвращать середину отрезка, но если записано, что он возвращает конкретный класс CPoint, а не интерфейс IPoint, то это и будет нарушением принципа инверсии зависимостей. Почему? Потому что тогда интерфейс не может вернуть ничего другого, кроме CPoint и будет от него зависеть. Пользователю этого интерфейса вполне возможно будет достаточно методов, описанных в IPoint, и тогда CPoint можно было бы заменить на любой другой объект, реализующий этот интерфейс, но тут мы сами себе закрываем эту возможность. К тому же при описании такого интерфейса можно случайно потащить за собой импортированный выше класс CPoint и весь его модуль, и неожиданно начать зависеть от всей кодовой базы сразу. Цель же этой второй части принципа инверсии зависимостей -- получить этакий слой абстракций, описывающий взаимодействие разных частей кода (по сути, набор интерфейсов), который никак не зависит от конкретных классов и поэтому эти самые конкретные классы можно взаимозаменять.
А вот с первой частью принципа всё хуже. Она звучит обманчиво понятно и скрывает за собой ту идею, которую далее в своей книге описывает Мартин: "Модули верхнего уровня не должны зависеть от модулей нижнего уровня. И те, и другие должны зависеть от абстракций".
Вот такая ситуация может у нас гипотетически сложиться. В одном модуле определён класс точки Point, в другом модуле определён класс отрезка Section, который создаёт и хранит ссылки на Point (композиция?), в третьем модуле определён класс графической сцены, который создаёт и хранит ссылки на Section (композиция?). В итоге модуль (верхнего уровня) сцены зависит от модуля (среднего уровня) отрезка, который зависит от модуля (нижнего уровня) точки.
Помня вторую часть принципа мы должны сказать: "Конкретные объекты не должны зависеть друг от друга, они должны все зависеть от интерфейсов" и переписать код примерно так:
Теперь конкретные классы не зависят от конкретных классов, они зависят от интерфейсов. И интерфейсы тоже зависят от интерфейсов. Каждый класс сам по себе в силу обычной инкапсуляции предоставляет интерфейс в виде набора публичных свойств и методов и прячет свою реализацию, но мы по сути тут добавляем новый слой абстракции: у каждого класса отдельно выделяется его интерфейс и отрезок Section теперь зависит от точки Point, а от интерфейса IPoint. Тут на самом деле спряталось очень важно изменение. Раз теперь отрезок Section не зависит от Point, он не может его создавать, как это было раньше:
class Section {
private:
Point p_beg;
Point p_end;
public:
Section() { ... }
...
}
Теперь эту композицию придётся менять на агрегацию. Это значит, что Section теперь должен получать или фабрику, с помощью которой он будет создавать IPoint, или уже два готовых IPoint:
class Section {
private:
IPoint *p_beg;
IPoint *p_end;
public:
Section(IPoint *p_beg, IPoint *p_end) {
this->p_beg = p_beg;
this->p_end = p_end;
}
...
}
И многие при обсуждении принципа инверсии зависимостей на этом останавливаются, хотя резонный вопрос -- а причем тут инверсия? Что тут инвертируется? Ответ на этот вопрос можно получить, если разнести все эти классы и интерфейсы по модулям. Как мы поступили бы, ничего не зная про принцип DIP?
Во-первых, это сохраняет всю ту ситуацию, для разрешения которой придумывался принцип DIP: модуль сцены зависит от модуля отрезка, а модуль отрезка зависит от модуля точки. Во-вторых, это нелогично и без всякого DIP. Если мы включаем интерфейс отрезка ISection в модуль отрезка, то как только кому-то понадобится интерфейс ISection, он будет вынужден подключить этот модуль вместе с самим Section, что сразу убивает весь смысл.
Так вот, главная (первая) идея принципа инверсии зависимостей в применении к этому примеру заключается в том, что мы смотрим совсем под другим углом, например, на ISection. Применяя принцип DIP, мы говорим, что ISection -- это не интерфейс отрезка, а это интерфейс того, что классу Scene именно нужно от отрезка. А раз так, то этот интерфейс надо, во-первых, переименовать, а во-вторых, перенести в модуль сцены:
Теперь не точка определяет, какой интерфейс она предоставляет, а отрезок определяет, какой интерфейс от точки ему нужен, а точка вынуждена ему соответствовать. И теперь действительно, все зависимости (между модулями!) переворачиваются с ног на голову: теперь модуль отрезка зависит от модуля сцены, а модуль точки от модуля отрезка. Когда смотрите на картинку с принципом инверсии зависимостей, всегда смотрите, нарисованы ли там границы модулей. Если не нарисованы, то автор картинки просто не понял Мартина.
В этом, собственно, и состоит идея инверсии зависимостей в задумке Мартина. Модули верхнего уровня (начальники) определяют абстрактные интерфейсы того, что им нужно от модулей среднего уровня (подчинённых), а модули среднего уровня вынуждены подстраиваться и зависеть от этих требований. А раньше было как? Подчинённые что-то там умели, а начальники зависели от того, что умели подчинённые и вынуждены были под их умения подстраиваться. Видите разницу? А по факту это всё только вопрос того, что гибко, а что жестко и не может меняться: что гибче - требования заказчиков или умения исполнителей?
Да, конечно, тут всё равно остаются вопросы. Во-первых, так ли уж правильно описывать не интерфейс того, что должен представлять собой Point, а интерфейс того, что нужно от Point кому-то другому? Или нужно и то и то? Если отрезку Section нужно от Point что-то одно, а кругу Circle нужно от Point что-то другое, то, как правильно отмечено в статьях, должен ли бедный Point наследоваться от сразу двух интерфейсов? (трёх? пяти? десяти?) Или это всё-таки должна быть зафиксированные в документах (в интерфейсе IPoint) сервисы, которые IPoint может предоставлять и всё?
Во-вторых, остаётся открытым всё-таки вопрос помещения в один модуль и интерфейсов, и конкретных классов. Возможно лучше всё-таки просто выносить интерфейсы в отдельные интерфейсные модули? А возможно тут вообще интерфейсы не нужны? А когда нужны? Об этом всегда нужно думать, чтобы борьба со сложностью не стала сложнее борьбы с абстракциями.
Не могу сказать, что для меня вопрос правильного применения принципа инверсии зависимостей окончательно закрыт. С одной стороны, идея автора этого принципа понятна. С другой стороны, очень многие его рассуждения просто не выдерживают никакой критики. Например, дальше в своей книге он даёт пример использования этого принципа для выстраивания "правильной" зависимости между такими объектами, как кнопка, включающая лампу, и лампа, управляемая кнопкой. Я специально так описал оба этих объекта, которые он приводит, чтобы показать, что вообще не очевидно, кто тут от кого должен зависеть, и кто из них тут низко или высокоуровневой. Любому, кто достаточно владеет приёмами ООП, будет претить любая жёсткая зависимость между этими объектами, потому что каждый из них может существовать отдельно друг от друга, и подключаются они друг к другу даже в реальности -- каким-то третьим объектом, проводом, который особым способом соединяет их. И это мы даже не вспомнили паттерн Наблюдатель, который в данном случае очень напрашивается!
Книжка полностью лежит тут, зима близко готовьтесь к сессии.
Содержание
Введение
1.1 Обозначения и сокращения
1.2 Что это и для кого
1.3 Зачем это всё
1.4 Методическая проблема
Основы ООП
2.1 Основные понятия
2.2 Хватит бла-бла-бла, давайте уже код!
2.3 Определение классов и создание объектов
2.4 Работа с объектами
2.5 Методы
2.6 Сокрытие свойств и методов
2.7 Конструкторы и деструкторы, жизненный цикл объектов
2.8 Наследование
2.9 Расширение и изменение объектов при наследовании
2.10 Композиция
2.11 Глубокое и поверхностное копирование, value- и reference- семантика
2.12 Наследование, композиция, агрегация, ассоциация, зависимость
2.13 [TODO] Диаграммы классов и прочий UML
2.14 Композиция или наследование
2.15 Помещение объектов в переменные различных типов
2.16 Полиморфизм
2.17 Приведение типов
2.18 Передача и возврат объектов из функций в С++
2.19 Проблемы работы с памятью и не только в С++ и не только
2.20 Технологии и практики управления долгоживущими объектами в C++
Приёмы ООП
3.1 Демистификация паттернов ООП
3.2 Обычные приёмы и антиприёмы ООП, паттерны и антипаттерны
3.3 Template Method
3.4 Prototype
3.5 Бесклассовое (прототипно-ориентированное) ООП
3.6 Интерфейсы
3.7 Множественное наследование
3.8 Примеси (миксины, mixins)
3.9 Singleton
3.10 Chain of Responsibility
3.11 Делегирование, объектная шизофрения
3.12 Strategy
3.13 Adapter
3.14 Iterator
3.15 Шаблоны С++, осторожно по минному полю
3.16 Стандартные контейнеры STL
3.17 Decorator
3.18 Proxy
3.19 Composite
3.20 Observer сам по себе и как пример mixin
3.21 Command
3.22 Factory Method, Abstract Factory
3.23 Abstract Factory ещё раз
3.24 Memento
3.25 Bridge
3.26 Visitor
3.27 Одинарная и двойная диспетчеризация
3.28 SOLID–принципы
3.29 Объектная интроспекция и рефлексия
Критика, похвала, замечания и предложения - приветствуются! Кроме этого, буду рад, если кто-то из хабровчан-преподавателей сочтёт возможным накатать и подписать официальную рецензию: вы же сами знаете, коллеги, всю нашу бюрократию :).
Комментарии (16)
Sazonov
26.01.2023 15:46+1Помимо SOLID стоит так же сразу обучать RAII и нормальным C++. Не нужно отговорок типа "ещё рано". Эти основы важнее чем SOLID для написания корректного кода. Краткий список пробоем: сырые указатели, типы из Си (при наличии c++ аналогов)
panzerfaust
Почему не могут быть? По определению окружность это такой эллипс, у которого radiusX == radiusY. Оба метода одно число возвращать должны. Не знаю, что вы тут под стретчами имеете в виду. Возможно, эксцентриситет. Но и это понятие для окружности существует. Итого пример вымученный.
А вымученный он потому, что все эти Cat extends Animal вроде бы корректны, но к практике отношения не имеют. Лучше объясняйте студентам кейс с Collections.unmodifiableList в джаве. Вот уже где нарушение LSP, которое может много приятных минут доставить.
grigorym Автор
Со студентами второго курса, которые (90%) только что узнали, что такое полиморфизм и вообще ещё ничего не знают ни про какие стандартные коллекции?
panzerfaust
Хотите сказать, что для людей, которые второй год грызут матан, стандартная либа джавы это что-то запредельное?
grigorym Автор
Не знаю, насчёт запредельного, но точно не приоритетное. Я попытался выписать всё более приоритетное (SOLID - одна из самых последних глав), получилось уже 250 страниц мелким шрифтом.
panzerfaust
Объяснять SOLID на пальцах без погружения в реальные задачи - это как демонстрировать релятивистские эффекты на примере движущихся жигулей. Эффекты не будут видны, как вы понимаете. Я тоже думал, что понимаю солид, пока не отрефакторил реальный проект на 100к строк и не ощутил достоинства и недостатки. Ваш труд рискует уйти в песок. Говорю в том числе как инженер, который собеседует джунов, у которых регулярно в CV встречается "пишу по SOLID"
grigorym Автор
В этом с вами полностью согласен, но что делать? Не упоминать солиды вообще?
panzerfaust
Я бы на вашем месте в качестве теории давал бы непосредственно статью Мартина плюс все, что успели написать по этому поводу в интернете за 20 лет. Пережевывать ее "на пальцах" смысла нет. Чем дальше, тем нелепее смотрятся эти "объяснения". Sapienti sat.
А в качестве практики написал бы код, в который бы аккуратно вплел нарушения принципов. Далее студенты должны провести код-ревью и обосновать, где какие принципы нарушаются.
sshikov
А зачем их упоминать? Ну вот скажите, вы разве не сможете без них разрабатывать? Ведь неправда же — сможете.
grigorym Автор
Я согласен с тем, что и такое мнение имеет право на жизнь, но у меня оно другое.
Это короткий односеместровый курс, там кот наплакал лекций. Поэтому я должен хорошо научить самым основам и дальше упомянуть всё остальное, чтобы дать кругозор и перспективу. Без интроспекции и рефлекции тоже, например, можно разрабатывать - но зацепить этими словами мозг и дать вводную, я считаю, нужно, поэтому так и делаю.
Наверное есть другие ВУЗы, там другие программы, может там ООП без солидов и паттернов изучают, я ж не настаиваю -- я просто делюсь собственным видением.
sshikov
Не, ну вы спросили, что делать. Я думаю, что если вам нужно расширить набор тем, то упомянуть SOLID конечно можно. Но это таки явно необязательная тема, которая студенту без опыта принесет мало пользы, как показывает моя практика. до таких принципов все же надо дозреть самому хоть немного, понять, что вам нужно как-то бороться со сложностью на таком уровне, который чуть выше одного файла кода.
Ну и потом, как по мне, когда слушатель без опыта, хорошие примеры кода им как раз полезнее, чем абстрактные принципы. То есть, именно показать, что вот класс, у которого скажем нарушается SRP. Показать, к каким негативным результатам это приводит, показать как сделать лучше. Уже потом дать этому название.
Ну и еще — если уж кругозор и перспектива — то стоит вообще отойти чуток от ООП, и показать хоть немного, как подобные проблемы решаются в других парадигмах. Скажем, что есть такая штука как ФП, и там это вот так.
Понятно, что хорошие примеры это тоже сложно.
sshikov
>применяемых при разработке объектно-ориентированных программ
Я бы все же чуть сменил формулировку. Не настаиваю, но: формулировка из википедии, не та что в самом начале, а чуть ниже, она лучше показывает, для чего и почему. Ну то есть:
То есть, подчеркнуть, что речь о поддержке и развитии. И значит, например, что если вам вашу систему и так легко поддерживать — то вам это все в общем-то пока и не нужно. И уже от этого отталкиваться, при обсуждении конкретных принципов.
panzerfaust
Оно может так где-то и работает, но только не в прикладном программировании. Мне сейчас приходится менторить джуна. Там любой мой совет в одно ухо влетает, в другое вылетает. Пока человек сам себе всю башку не разобьет обо что-то - понимания не придет. А SOLID это ровно такая вещь, которая требует в щепки разбитого лба. Ровно поэтому все 100500 статей "Щас объясню солид на JS на примере кошек" имеют нулевой эффект.
Maksclub
браво
— Макс, че там с диффуравнением и комплексным числом? Ммм, спасибо. А кстати, подскажи, почему тут кошечка от животного наследуется?
— Макс, с кошкой понял, но вот по лабораторной аналогия: отправитель сигнала если унаследуем от общего отправителя, то как обернуть закешированный отправитель, типа чтобы был таким же отправителем — делаю работу, с кошками понятно, но тут лишние абстракции мешают, а наследоваться можно только от одного, мне еще раз отнаследоваться и поменять в приемнике тип?...