Open nin-jin opened 4 years ago
"Для неизменяемых (в том числе не ссылающихся на изменяемые) объектов этот принцип выполняется автоматически, так как неоткуда взяться ограничению подтипа." - Ковариантность выполняется автоматически, но не LSP Flow example А еще LSP устарел из-за того, как ЯП работают с исключениями (сарказм)
Да, я совсем не рассмотрел вопрос перегрузки функций.
https://page.hyoo.ru/#!=3skw_ac25gk
Здравствуйте, меня зовут Дмитрий Карловский и я.. хочу поведать вам о фундаментальной особенности систем типов, которую зачастую или вообще не понимают или понимают не правильно через призму реализации конкретного языка, который ввиду эволюционного развития имеет много атавизмов. Поэтому, даже если вы думаете, что знаете, что такое "вариантность", постарайтесь взглянуть на проблематику свежим взглядом. Начнём мы с самых основ, так что даже новичок всё поймёт. А продолжим без воды, чтобы даже профи было полезно для структурирования своих знаний. Примеры кода будут на псевдоязыке похожем на TypeScript. Потом будут разобраны подходы уже нескольких реальных языков. А если же вы разрабатываете свой язык, то данная статья поможет вам не наступить на чужие грабли.
Аргументы и параметры
Параметр - это то, что мы принимаем. Описывая тип параметра мы задаём ограничение на множество типов, которые можно нам передать. Несколько примеров :
Аргумент - это то, что мы передаём. В момент передачи аргумент всегда имеет какой-то конкретный тип. Тем не менее, при статическом анализе конкретный тип может быть не известен из-за чего компилятор оперирует опять же ограничениями на тип. Несколько примеров:
Подтипы
Типы могут могут образовывать иерархию. Подтип - это частный случай надтипа. Подтип может образовываться путём сужения множества возможных значений надтипа. Например, тип Natural является подтипом Integer и Positive. И все трое одновременно являются подтипами Real. В то же время типы Positive и Integer являются пересекающимися, но ни один из них не является сужением другого.
Другой способ образовать подтип - расширить его, объединив с другим ортогональным ему типом. Например, есть "цветная фигура" имеющая свойство "цвет", а есть "квадрат" имеющий свойство "высота". Объединив эти типы мы получим "цветной квадрат". А добавив "круг" с его "радиусом" можем получить "цветной цилиндр".
Иерархии
Для дальнейшего повествования нам понадобится небольшая иерархия животных и аналогичная ей иерархия клеток.
Всё, что ниже на рисунке является сужением типа выше. Клетка с питомцем может содержать лишь домашних животных, но не диких. Клетка с собакой может содержать лишь собак.
Ковариантность
Самое простое и понятное - это ограничение надтипа или ковариантность. В следующем примере параметр функции ковариантен указанному для него типу. То есть функция может принимать как сам этот тип, так и любой его подтип, но не может принимать надтипы или иные типы.
Так как мы ничего не меняем в клетке, то спокойно можем передавать функции клетку с кошкой, так как она - не более, чем частный случай клетки с питомцем.
Контравариантность
Чуть сложнее понять ограничение подтипа или контравариантность. В следующем примере параметр функции контравариантен указанному для него типу. То есть функция может принимать как сам этот тип, так и любой его надтип, но не может принимать подтипы или иные типы.
Мы не можем передать клетку с кошкой, так как функция может засунуть туда собаку, что не допустимо. А вот клетку с любым животным можно смело передавать, так как и кошку и собаку вполне можно туда помещать.
Инвариантность
Ограничение подтипа и надтипа могут быть одновременно. Такой случай называется инвариантностью. В следующем примере параметр функции инвариантен указанному для него типу. То есть функция может принимать только указанный тип и никакой больше.
Функция
replacePet
наследует ограничения у тех функций, которые она внутри себя использует: уtouchPet
она взяла ограничение на надтип, а уpushPet
- ограничение на подтип. Если мы передадим ей клетку с любым животным, то она не сможет передать её в функцию touchPet, которая не умеет работать с лисами (дикое животное просто откусит палец). А если передадим клетку с кошкой, то не получится вызвать ужеpushPet
.Бивариантность
Нельзя не упомянуть и экзотическое отсутствие ограничений - бивариантность. В следующем примере функция может принять любой тип являющийся надтипом или подтипом.
В неё можно передать клетку с животным. Тогда она проверит, что в клетке находится питомец, иначе положит внутрь случайного питомца. А можно передать и, например, клетку с кошкой, тогда она просто ничего не сделает.
Обобщения
Некоторые считают, что вариантность как-то связана с обобщениями. Зачастую потому, что про вариантность часто объясняют на примере обобщённых контейнеров. Однако, во всём повествовании у нас до сих пор не было ни единого обобщения - сплошь конкретные классы:
Сделано это было, чтобы показать, что проблемы вариантности никак с обобщениями не связаны. Обобщения нужны лишь, чтобы уменьшить копипасту. Например, код выше можно переписать через простое обобщение:
И теперь можно создавать экземпляры любых клеток:
Декларация ограничений
Обратите внимание, что сигнатуры у всех четырёх ранее приведённых функций совершенно одинаковые:
То есть такое описание принимаемых параметров функции не обладает полнотой - по нему нельзя сказать что в функцию можно передавать. Ну разве что однозначно видно, что клетку с лисой передавать в неё точно не стоит.
Поэтому в современных языках есть средства для явного указания какие у параметра ограничения на типы. Например, модификаторы in и out в C#:
К сожалению, в C# для каждого варианта модификаторов необходимо заводить по отдельному интерфейсу. Кроме того, насколько я понял, бивариантность в C# вообще невыразима.
Выходные параметры
Функции могут не только принимать, но и возвращать значения. В общем случае, возвращаемое значение может быть и не одно. В качестве примера возьмём функцию принимающую клетку с питомцем и возвращающую двух питомцев.
Такая функция эквивалентна функции, принимающей помимо одного входного параметра, ещё и два выходных.
Внешний код выделяет на стеке дополнительную память, чтобы функция положила в неё всё, что хочет вернуть. А по завершении, вызывающий код уже сможет воспользоваться этими контейнерами в своих целях.
Из эквивалентности этих двух функций следует, что возвращаемые функцией значения в отличие от параметров всегда контравариантны указанному выходному типу. Ибо функция может в них писать, но не может из них читать.
Методы объектов
Методы объектов - это такие функции, которые принимают дополнительный указатель на объект в качестве неявного параметра. То есть следующие две функции эквивалентны.
Однако, важно отметить, что метод, в отличие от обычной функции, является также и членом класса, что является расширением типа. Приводит это к тому, что появляется дополнительное ограничение надтипа, для функций, вызывающих этот метод:
Мы не можем передать в неё такой надтип, где метод
pushPet
ещё не определён. Это похоже на случай с инвариантностью тем, что есть ограничение и снизу и сверху. Однако, место определения методаpushPet
может быть выше по иерархии. И именно там будет ограничение надтипа.Принцип Подстановки Барбары Лисков (LSP)
Многие считают, что отношение надтип-подтип определяется не исходя из упомянутых ранее способов сужения и расширения типа, а возможностью подставить подтип в любое место использования надтипа. По всей видимости, причина этого заблуждения именно в LSP. Однако, давайте прочитаем определение этого принципа внимательно, обратив внимание, что первично, а что вторично:
Для неизменяемых (в том числе не ссылающихся на изменяемые) объектов этот принцип выполняется автоматически, так как неоткуда взяться ограничению подтипа.
С изменяемыми же всё сложнее, так как следующие две ситуации являются взаимоисключающими для принципа LSP:
A
есть подклассB
, где полеB::foo
является подтипомA::foo
.A
есть метод, который может изменить полеA::foo
.Соответственно, остаётся лишь три пути:
монастырьфункциональное программирование, где все объекты неизменяемые, а значит параметры их принимающие ковариантны объявленному типу.TypeScript
В тайпскрипте логика простая: все параметры функции считаются ковариантными (что не верно), а возвращаемые значения - контравариантными (что верно). Ранее было показано, что параметры функции могут иметь совершенно любую вариантность в зависимости от того, что эта функция с этими параметрами делает. Поэтому получаются такие вот казусы:
Чтобы решить эту проблему приходится помогать компилятору довольно нетривиальным кодом:
Попробовать онлайн
FlowJS
FlowJS имеет более продвинутую систему типов. В частности, в описании типа можно указать его вариантность для обобщённых параметров и для полей объектов. На нашем примере с клетками выглядит это примерно так:
Попробовать онлайн
Бивариантность тут невыразима. К сожалению, мне не удалось найти способа более удобно задавать вариантность не описывая типы всех полей явно. Например, как-то так:
C Sharp
C# изначально проектировался без какого-либо понимания вариантности. Однако, впоследствии в нём были добавлены in и out модификаторы параметров, что позволило компилятору правильно проверять типы передаваемых аргументов. К сожалению, использовать эти модификаторы опять же не очень удобно.
Попробовать онлайн
Java
К Java возможность переключения вариантности была добавлена довольно поздно и лишь для обобщённых параметров, которые сами-то появились сравнительно недавно. Если же параметр не обобщённый, то беда.
Попробовать онлайн
C++
C++ благодаря мощной системе шаблонов может выразить различные вариантности, но кода, конечно получается много.
Попробовать онлайн
D
D не имеет вменяемых средств явного указания вариантности, зато он умеет сам выводить типы исходя из их использования.
Попробовать онлайн
Эпилог
На этом пока всё. Надеюсь изложенный материал помог вам лучше понять ограничения на типы, и как они реализованы в разных языках. Где-то лучше, где-то хуже, где-то никак, но в целом - так себе. Возможно именно вы разработаете язык, в котором всё это будет реализовано и удобно, и типобезопасно. А пока присоединяйтесь к нашему телеграм чату, где мы иногда обсуждаем теоретические концепции языков программирования.