Здравствуйте, меня зовут Дмитрий Карловский. А вы на канале Core Dump, где мы берём разные темы из компьютерной науки и деконструируем их по полочкам. А на этот раз мы начнём деконструировать принципы SOLID начиная с наиболее конкретного.
В далёком 1987 году Барбара Лисков сформулировала принцип разработки имени себя.
Liskov Substitution Principle
Он позволяет понять правильно вы написали полиморфный код или нет. Но прежде чем его сформулировать нам надо разобраться с некоторыми понятиями, которые входят в определение..
Отношение "супертип-подтип"
Все данные в нашей программе принадлежат тому или иному типу. Тип определяет множество возможных значений и их семантику. Один тип может полностью включать в себя другой. В таком случае второй тип является подтипом первого. Таким образом типы могу образовывать иерархию. Рассмотрим пример с числами..
Как тип целых так и тип положительных чисел по отдельности являются частными случаями типа вещественных чисел, а значит являются его подтипами. В то же время целые не включают в себя все положительные. А положительные не включают в себя все целые. Поэтому эти типы не состоят друг с другом в отношении "супертип-подтип". А вот натуральные числа являются одновременно и целыми и положительными, поэтому тип натуральных чисел является подтипом обоих этих типов.
Отношение "супертип-подтип" является транзитивным, то есть если один тип является подтипом другого, а другой — третьего, то и первый является подтипом третьего.
Сильная и слабая типизация
Тут правда необходимо иметь ввиду специфику представления значения разных типов в памяти. Например, эквивалентные значения целочисленного и вещественного типов обычно представляются разной последовательностью бит. Так что в некоторых языках эти типы не будут находиться в родственных отношениях. Однако, есть языки, умеющие автоматически преобразовывать значения из одного представления в другое — в них более узкий тип является подтипом более широкого, который позволяет хранить большее число вариантов значений.
На иллюстрации мы видим систему числовых типов в слабо типизированном языке, который умеет неявно производить преобразование типа, если это не приводит к потере информации.
Полиморфизм
Полиморфизм — это способность одного и того же кода работать с аргументами разных типов.
В данном примере, процедура draw
у нас принимает на вход произвольную фигуру. И какую бы фигуру мы ей ни передали — процедура её всё равно нарисует.
Суть LSP — повсеместная ковариантность
Наконец, теперь мы можем сформулировать принцип подстановки Барбары Лисков: "любые функции, которые используют базовый тип, должны иметь возможность использовать подтипы базового типа, не зная об этом и не нарушая желаемых свойств программы".
Например, если функция принимает на вход питомца, то передать ей можно хоть кошечку, хоть собачку, а вот дикую лису нельзя, иначе откусит палец. Говоря современным языком, принцип LSP гласит: все параметры всех функций должны быть ковариантными, то есть ограничивающими дерево типов сверху относительно задекларированного для данного параметра.
Звучит вроде бы логично, однако...
Контравариантность не вписывается в LSP
Давайте рассмотрим функцию, которая принимает на вход коробку для питомца и засовывает в неё какого-то, нам не известного, питомца.
Если мы позволим передавать в неё клетку для собак, то вскоре можем столкнуться с абсурдной ситуацией, когда вытащим оттуда казалось бы собаку и скажем "Апорт", а она замяукает и нассыт вам в тапки. То есть подтип передавать в эту функцию нельзя. А вот супертип очень даже можно, ведь в клетку для животных можно засовывать хоть домашнего, хоть дикого животного. Получается своего рода инверсия LSP. То есть иерархия типов ограничивается не сверху, а снизу. Такое ограничение имеет название "контравариантность".
Виды вариантностей
Получается, что возможность подстановки одного типа вместо другого зависит не столько от типа параметра, сколько от того, что функция с этим параметром делает. И тут могут быть самые разные ограничения..
- Только чтение — ковариантность (ограничение сверху)
- Только запись — контравариантность (ограничение снизу)
- Чтение и запись:
- инвариантность (ограничение снизу и сверху)
- бивариантность (без ограничений)
То есть, если мы хотим писать корректные программы, то мы вынуждены явно нарушать LSP во многих случаях.
Применимость LSP
Все эти разные вариантности появляются лишь когда мы изменяем какое-то состояние. Но если мы работаем лишь с чистыми функциями, которые ничего не изменяют, то ковариантность всех параметров и, как следствие, LSP мы получаем автоматически. Хотим мы того или нет — от нас это не зависит.
- Функциональное Программирование
С другой стороны, любая небесполезная функциональная программа содержит не только чистую, но и грязную часть, где вопросы вариантности встают в полный рост.
Правильный LSP
Ладно, давайте пофантазируем и попробуем сформулировать LSP здорового человека, учитывающего все озвученные ранее нюансы...
"Функция может не знать конкретный передаваемый ей тип, но она обязана ограничить множество принимаемых типов так, чтобы ей нельзя было передать такой тип, который сломает ожидаемое поведение программы".
Звучит с одной стороны всё ещё так же размыто, как и оригинальная формулировка, а с другой — самоочевидно. Понятное дело, что если есть возможность правильно написать типы, то стоит это делать правильно.
- Статическая типизация
Следовать ли LSP?
Как было показано ранее, если мы хотим писать корректные программы, то не можем следовать LSP. Ведь это принцип, а не, например, паттерн. Принципа либо придерживаются во всём, либо придерживаются чего-то другого, что лишь иногда соответствует тому принципу. Принципу нельзя придерживаться частично, так же как нельзя быть немножко беременной.
LSP, на текущий момент, — это устаревшая концепция, не учитывающая многие случаи. Поэтому применять её как руководство к действию ни в коем случае нельзя. А важно понимать вариантность ваших параметров и то, как конкретный язык программирования работает с типами.
- LSP :-(
- Вариантность :-)
Что ещё почитать о вариантности?
Для лучшего понимания вопроса вариантности рекомендую свою статью на эту тему. Там я доступным языком рассказываю всю эту сложную теорию, и привожу примеры кода на разных языках программирования, иллюстрирующие разные типы вариантности.
Теория программирования: Вариантность
К сожалению, в современных языках поддержка вариантности находится в зачаточном состоянии. Где-то она не поддерживается вообще. Где-то вариантности жёстко захардкожены. Где-то есть куцые механизмы явного указания вариантности. И в совсем редких языках можно встретить не только автоматическое выведение типов параметров, но и выведение их вариантности, что очень круто, так как ручное разруливание вариантности порой слишком многословно.
Продолжение следует..
Лайк
Подписка
Комментарий
Поделись-ка
Если данный разбор показался вам полезным, то дайте мне об этом знать посредством лайка. А так же поделитесь ссылкой на него со своими коллегами.
Если же вы не согласны с какой-либо мыслью или, наоборот, чувствуете какую-то недосказанность и хотите дополнить своими идеями, то жду ваших комментариев.
Если вы не боитесь подискутировать со мной в прямом эфире или даже готовы стать соавтором будущих разборов, то пишите телеграмы.
Наконец, подписывайтесь на канал, чтобы не пропустить дальнейшие разборы. Нам ещё много чего с вами нужно будет обсудить.
На этом пока что всё. С вами был немножко программер Дмитрий Карловский.
mayorovp
Ещё как вписывается. Только нужно понимать её правильно, а не так как её понимаете вы.
Ошибка — вот в этой картинке:
Тип "коробка для питомца" не является подтипом для "коробки для животного". Эти типы не являются совместимыми друг с другом ни в одну сторону. Иными словами, понятие "коробка" инвариантно.
Конечно же, вы, как программист, вольны нарушить LSP и обойти это ограничение. Но в таком случае система типов языка перестанет защищать вас от ошибок.
Однако, можно выделить два семейства интерфейсов, наподобие вот таких:
Первый интерфейс образует иерархию подтипов, аналогичную показанной на картинке — то есть
TakeOnlyBox<Pet>
является подтипомTakeOnlyBox<Animal>
. Такое семейство типов называется ковариантным.Второй интерфейс образует иерархию подтипов, "развёрнутую" в обратную сторону:
PutOnlyBox<Pet>
является супертипомPutOnlyBox<Animal>
. Такое семейство типов называется контравариантным.Так вот, если функция
pushPet
с картинки будет принимать не просто коробку, а интерфейсPutOnlyBox<Pet>
— это никак не нарушит LSP.sshikov
Прям с языка сняли…
nin-jin Автор
Отношение подтипов и совместимость типов — разные понятия. Фактически лсп утверждает, что второе должно совпадать с первым. Однако, в статье показано, что это не так.
Инвариантность дженериков — это костыль в некоторых языках. Не распространяйте его на всё программиронание. В статье на которую я дал ссылку вы можете найти код на языке D, который умеет правильно выводить вариантность (совместимость). Без глупостей про тотальную инвариантность дженериков.
lgorSL
Если вы считаете, что в "клетку для собак" нельзя сажать других животных, то такую клетку нельзя считать подтипом "клетки для животных". Подтип должен уметь делать всё то же, что и родитель. Например-вмещать в себя кошек.
Комментатор выше то же самое написал, только на чуть другом языке.
P.s. с вашей точки зрения, "коробка для животных с собакой внутри" и "коробка для собак с собакой внутри" — это одно и то же или нет?
nin-jin Автор
https://en.wikipedia.org/wiki/Subtyping#Width_and_depth_subtyping
mayorovp
И что мы должны увидеть по вашей ссылке? Если вы сейчас про от это:
То не забывайте прочитать немного дальше: