Тема вариантов в программировании вызывает кучу сложностей в понимании, по мне это проблема в том, что в качестве объяснения берут не всегда успешные метафоры - контейнеры.
Я надеюсь что может у меня получиться объяснить эту тему с другой стороны используя метафоры присвоения в разрезе лямбд.
Зачем вообще эта вариантность нужна ?
В целом без вариантности можно жить и спокойно программировать, это не такая уж архиважная тема, у нас есть множество примеров языков программирования в которых это качество не отображено.
Ко-вариантность это о типах данных и их контроле со стороны компиляторов. И ровно с этого места надо откатиться и сказать о типах данных и зачем это нам нужно.
Flashback к типам
Типы данных сами по себе тоже не являются сверхважной темой, есть языки в которых тип данных не особенно нужны, например ассемблер, brainfuck, РЕФАЛ.
В том же РЕФАЛ или ассемблере очень легко перепутать к кому типу относиться переменная, и очень легко, например можно допустить что из одной строки я вычту другую строку, просто опечатка, никакого злого умысла.
В языках с поддержкой типов, компилятор увидел бы это опечатку и не дал бы мне скомпилировать программу, но например JS
> 'str-a' - 'str-b'NaN
JS (JavaScript) Спокойно этот код проглатывает, мне скажут что этоне баг, это фича, ок, допустим, тогда я возьму Python
>>> 'str-a' - 'str-b'Traceback (most recent call last): File "<stdin>", line 1, in <module>TypeError: unsupported operand type(s) for -: 'str' and 'str'
Или Java
jshell> "str-a" - "str-b"| Error:| bad operand types for binary operator '-'| first type: java.lang.String| second type: java.lang.String| "str-a" - "str-b"| ^---------------^
То есть я клоню к тому, что считать багом или фичей - зависит от создателей языка.
А мне как пользователю например вообще без разницы на каком языке написана та или иная программа, мне важно чтоб она работала.
А как программисту, решающему задачу конкретного пользователя, я выберу тот язык, который будет удобен мне для решения задачи и я не хотел бы сильно заварчиваться на особенности языков, знать особенности работы с тем или иным типом данным.
Еще пример: может быть такой мой сценарий, допустим вчера я написал на Groovy вот такой код
groovy> def fun1( a, b ){groovy> return a - bgroovy> }groovy> println 'fun1( 5, 2 )='+fun1( 5, 2 )groovy> println "fun1( 'aabc', 'b' )="+fun1( 'aabc', 'b' )groovy> println 'fun1( [1,2,3,4], [2,3] )='+fun1( [1,2,3,4], [2,3] )fun1( 5, 2 )=3fun1( 'aabc', 'b' )=aacfun1( [1,2,3,4], [2,3] )=[1, 4]
А сегодня так на JS в другом проекте
> fun1 = function( a, b ){ return a - b }[Function: fun1]> fun1( 5, 2 )3> fun1( 'aabc', 'b' )NaN> fun1( [1,2,3,4], [2,3] )NaN
И вот таких не совпадений типов данных может быть много и мне действительно надо знать особенности того или иного языка.
Окей, я понимаю, что я сейчас выдумываю на ходу разные проблемы - но это не значит, что прям сейчас надо бросать известный вам язык и переходить на другой язык программирования.
Речь о типах данных
Вариантность как и ко/контр вариантность - это речь о типах данных и их отношениях между собой.
Некоторые языки программирования создавались, чтобы избежать выше описанных проблем.
Один из способов избежать - это введение системы типов данных.
Вот пример на языке TypeScript
function sub( x : number, y : number ) { return x - y;}console.log( sub(5,3) )
Этот код успешно скомпилируется в JS.
А вот этот
function sub( x : number, y : number ) { return x - y;}console.log( sub("aa","bb") )
Уже не скомпилируется - и это хорошо:
> tsc ./index.tsindex.ts:5:18 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.5 console.log( sub("aa","bb") )~~~~Found 1 error.
В примере выше функцияsub
требует принимать в
качестве аргументов переменные определенного типа, не любые, а
именноnumber
.
Контроль за типы данных я возлагаю уже компилятору TypeScript
(tsc
).
Инвариантность
Рассмотрим пока понятие Инвариантность, согласно определению
Инвариант это свойство некоторого класса (множества) математических объектов, остающееся неизменным при преобразованиях определённого типа.
Пусть A множество и G множество отображений из A в A. Отображение f из множества A в множество B называется инвариантом для G, если для любых a A и g G выполняется тождество f(a)=f(g(a)).
Очень невнятное для не посвященных определение, давай те чуть проще:
Инвариантность- это такое качество операций над данными, при котором тип данных в передаваемых в функцию и возвращаемый тип является один и тем же.
Рассмотрим пример операции присвоения переменной, в JS допускается вот такой код
> fun1 = function( a, b, c ){... let r = b;... if( a ) r = c;... return r + r;... }[Function: fun1]> fun1( 1==1, 2, 3 )6> fun1( 1==1, "aa", "b" )'bb'> fun1( 1==1, 3, "b" )'bb'> fun1( 1!=1, 3, "b" )6> fun1( 1!=1, {x:1}, "b" )'[object Object][object Object]'
В примере переменная r - может быть и типа string и number и объектом, со стороны интерпретатора сказать какого типа данных возвращает функция fun1 нельзя, пока не запустишь программу.
Так же нельзя сказать какого типа будет переменная r. Тип результата и тип переменной r зависит от типов аргументов функции.
Переменная r по факту может иметь два разных типа:
-
В конструкции
let r = b
, переменная r будет иметь такой же тип, как и переменная b. -
В конструкции
r = c
, переменная r будет иметь такой же тип, как и переменная c.
В целом, такое не определенное поведение может сказаться на последующей логике поведения программы негативно.
Можно наложить явным образом ограничения на вызов функции и проверять какого типа аргументы, например так:
> fun1 = function( a, b, c ){... if( typeof(b)!=='number' )throw "argument b not number";... if( typeof(c)!=='number' )throw "argument c not number";... let r = b;... if( a ) r = c;... return r + r;... }[Function: fun1]> fun1( true, 1, 2 )4> fun1( true, 'aa', 3 )Thrown: 'argument b not number'
Это уже лучше, хоть об ошибке мы узнаем, во время выполнения, но она уже не приведет к негативным последствиям.
Другой же аспект, в том что операция +, - и др при операциях над числами - возвращают числа - это и естьинвариантность(в широком смысле), а вот при над числами и строками или различными типами данных - результат уже менее предсказуем.
В языках со строгой типизацией операция конструкцияlet r =
b
и следующая за нейr = c
не допустима, она может
быть допустима если мы укажем типы аргументов.
Пример Typescript:
function fun1( a:boolean, b:number, c:number ){ let r = b; if( a ) r = c; return r + r;}function fun2( a:boolean, b:number, c:string ){ let r = b; if( a ) r = c; return r + r;}
И результат компиляции
> tsc ./index.ts index.ts:9:13 - error TS2322: Type 'string' is not assignable to type 'number'.9 if( a ) r = c;~Found 1 error.
Здесь в ошибки говориться явно, что переменная
типаstring
не может быть присвоена переменной
типаnumber
.
Вариантность- в компиляторах, это проверка допустимости присвоения переменной одного типа значения другого типа.
Инвариантность- это такой случай, когда переменной одного типа присваивается (другая или эта же) переменная этого же типа.
Теперь вернемся к строгому определению:выполняется
тождество f(a)=f(g(a))
То есть допустим у нас есть функции TypeScript:
function f(a:number) : number { return a+a;}function g(a:number) : number { return a;}console.log( f(1)===f(g(1)) )
Этот код - вот прям сторого соответствует определению.
В контексте программированияИнвариантность- это не свойство значения функций, а соответствие типов данных, т.е. вот код ниже абсолютно валиден
function f(a:number) : number { return a+a;}function g(a:number) : number { return a-1;}let r = f(1)r = f(g(1))
а такой код
function f(a:number) : number { return a+a;}function g(a:number) : string { return (a-1) + "";}let r = f(1)r = f(g(1))
Уже невалиден (не корректен), так как:
-
функция g возвращает тип string
-
а функция f требует тип number в качестве аргумента
и вот такую ошибку обнаружит компилятор TypeScript.
Первый итог
Вариантностьи другие ее формы, как напримерИн/Ко/Контр вариантность- это качество операции присвоения значения переменной или передачи аргументов в функцию, в которой проверяется типы данных передаваемых/принимаемых в функцию и переменную.
Ко-вариантность
Для объяснения ко-вариантности и контр-вариантности, мне придется прибегнуть не к TypeScript, а к другому языку - Scala, причины я поясню ниже.
Вы наверно уже слышали про ООП и наследование, про различные принципы Solid
Ко-вариантностьобычно объясняют через наследование, и что наследуются все свойства и методы родительского класса - это верно, рассмотрим пару примеров
Ко-вариантностьэто такое качество операции присвоения значения переменной значение переменной другого типа, при котором сохраняются все свойства и операции.
Есть несколько типов чисел и их можно расположить в следующей иерархии:
-
Натуральные числа N
-
N натуральные числа, включая ноль: {0, 1, 2, 3, }
-
N* натуральные числа без нуля: {1, 2, 3, }
-
-
Целые числа Z - обладают знаком (+/-) включают в себя натуральные
-
Рациональные числа Q - дроби (два целых числа), включают в себя все бесконечное множество Z
-
Вещественные числа R - это и рациональные и иррациональные числа (например ПИ, e, )
-
Комплексные числа C - числа вида a+bi, где a,b - вещественные числа, а i - мнимая единица
Давай те рассмотрим более подробно:
Числа мы можем условно расположить согласно такой иерархии
-
any - любой тип данных
-
number - некое число
-
int - целое число
-
double - (приближенное) дробное число
-
-
string - строка
-
так мы можем в языке TypeScript написать функции
function sum_of_int( a:int, b:int ) : int { return a+b; }function sum_of_double( a:double, b:double ) : double { return a+b; }function compare_equals( a:number, b:number ) : boolean { a==b }
в случае
let res1 : int = sum_of_int( 1, 2 )
Это будет случайинвариантногоприсваивания, т.к. типы полностью совпадают - результат вызова int, и переменная которая принимает результат то же int.
Рассмотрим случайко-вариантногоприсваивания
let res1 : number = sum_of_int( 1, 2 ) res1 = sum_of_double( 1.2, 2.3 )
В данном примере res1 - это тип number.
В первом вызове res1 = sum_of_int( 1, 2 ), переменная res1 примет данные типа int, и это корректно, т.к. int это подтип number и по определению сохраняются все свойства и методы класса number
Во втором вызове res1 = sum_of_double( 1.2, 2.3 ) - переменная res1 примет данные типа double и это тоже корректно, так же по определению
О каких же операциях говорят что сохраняются? а все те же, мы все так же как и в первом, так и во втором случае можем выполнить операции проверки на равенство и д.р. для переменной res1:
let res1 : number = sum_of_int( 1, 2 )let res2 : number = sum_of_doube( 1.2, 2.3 )if( compare_equals(res1, res2) ){ ...}
ок, это работает, но компилятор нам нужен чтоб он за нас решал проблемы с типами, рассмотрим еще более выпуклый пример
Допустим у нас есть фигуры: прямоугольник Box и круг Circle
class Box { width : number height : number constructor( w: number, h: number ){ this.width = w; this.height = h; }}class Circle { radius : number constructor( r: number ){ this.radius = r }}
И нам надо подсчитать сумму площадей, прямоугольники можно хранить в одном массиве, а круги в другом
let boxs : Box[] = [ new Box(1,1), new Box(2,2) ]let circles : Circle[] = [ new Circle(1), new Circle(2) ]
Мы напишем 2 функции по подсчету площади, одну для прямоугольников, другую для кругов
function areaOfBox( shape:Box ):number { return shape.width * shape.height }function areaOfCircle( shape:Circle ):number { return shape.radius * shape.radius * Math.PI }
Тогда для подсчета общей суммы площадей код будет примерно таким:
boxs.map( areaOfBox ).reduce( (a,b,idx,arr)=>a+b ) + circles.map( areaOfCircle ).reduce( (a,b,idx,arr)=>a+b )
Все выше выглядит ужасно, если вы знакомы с ООП или/и с базовой логикой (родовые, видовые понятия).
Первое, что должно броситься в глаза - так это что свойствоплощадьприменимо к обеим фигурам, а для подсчета суммы площадей, нет прямой необходимости как-то различать типы фигур.
А по сему можно выделить общее абстрактное понятиеФигураи добавить в это абстрактное метод/свойство - area():number.
interface Shape { area():number}
Вторым шагом, это указать что классы Box и Circle реализуют интерфейс Shape, и перенести areaOfBox, areaOfCircle как реализацию area.
class Box implements Shape { width : number height : number constructor( w: number, h: number ){ this.width = w; this.height = h; } area():number { return this.width * this.height }}class Circle implements Shape { radius : number constructor( r: number ){ this.radius = r } area():number { return this.radius * this.radius * Math.PI }}
Теперь нет необходимости разделять прямоугольники и круги в разные массивы, и писать сложный код
let shapes : Shape[] = [ new Box(1,1), new Box(2,2), new Circle(1), new Circle(2) ]shapes.map( s => s.area() ).reduce( (a,b,idx,arr)=>a+b )
И в данном примере,ко-вариантностьпроявляется в инициализации массива
Массив определен как массив элементов типа Shape, мы инициализируем (т.е. присваиваем начальное значение) элементами другого типа под типа (Box, Circle).
Ключевой момент в том, что Box и Circle реализуют необходимые свойства и методы которые требует интерфейс Shape.
Компилятор отслеживает что присваиваемые значения реализуют заданное соглашение, т.е.
Компилятор по факту отслеживает конструкциюlet a =
b
, и возможны несколько сценариев:
-
переменная a и b - одного типа, тогдаинвариантнаяоперация присвоения
-
переменная a является базовым типом, а переменная b - подтипом переменной a - тогдако-вариантнаяоперация присвоения
-
переменная a является подтипом переменной b, а переменная b - базовым (родительским) типом - тогда этоконтр-вариантнаяоперация - и обычно компилятор блокирует такое поведение.
-
между переменными a и b - нет общих связей - и тут компилятор блокирует то же поведение.
И вот пример, по пробуем добавить еще один класс который не реализует интерфейс Shape
class Foo {}let shapes : Shape[] = [ new Box(1,1), new Box(2,2), new Circle(1), new Circle(2), new Foo() ]shapes.map( s => s.area() ).reduce( (a,b,idx,arr)=>a+b )
Результат компиляции - следующая ошибка:
> tsc index.tsindex.ts:31:84 - error TS2741: Property 'area' is missing in type 'Foo' but required in type 'Shape'.31 let shapes : Shape[] = [ new Box(1,1), new Box(2,2), new Circle(1), new Circle(2), new Foo() ] ~~~~~~~~~index.ts:2:5 2 area():number ~~~~~~~~~~~~~ 'area' is declared here.Found 1 error.
Для типа Foo не найдено свойство area, которое определенно в типе Shape.
Тут уместно упомянуть о SOLID
L - LSP - Принцип подстановки Лисков (Liskov substitution principle): объекты в программе должны быть заменяемыми на экземпляры их подтипов без изменения правильности выполнения программы. См. такжеконтрактное программирование.
Контр-вариантность
Контр-вариантность, уже сложнее объяснить, для меня примеры с длегатами действовали на нервы, я же разобрался на примере с лямбд.
В качестве примера, возьму язык Scala и подробно попытаюсь его разобрать:
package xyz.cofe.sample.invobject App { // Функция, на вход String, на выход Boolean, или кратко: (String)=>Boolean def strCmp(a:String):Boolean = a.contains("1") // Функция, на вход Int, на выход Boolean, или кратко: (Int)=>Boolean def intCmp(a:Int):Boolean = a==1 // Функция, на вход String, на выход Boolean, или кратко: (Any)=>Boolean def anyCmp(a:Any):Boolean = true def main(args:Array[String]):Unit = { // Инвариантное присвоение Boolean = Boolean val call1 : Boolean = strCmp("a") // Ко-вариантное присвоение Any = Boolean val call2 : Any = strCmp("b") // Инвариантное присвоение: (String)=>Boolean = (String)=>Boolean val cmp1 : (String)=>Boolean = App.strCmp; // Ко-вариантное присвоение (String)=>Boolean = (Any)=>Boolean val cmp2 : (String)=>Boolean = App.anyCmp // Инвариантное присвоение: (String)=>Boolean = (String)=>Boolean val cmp3 : (Any)=>Boolean = App.anyCmp // !!!!!!!!!!!!!!!!!!!!!!! // Тут будет ошибка // Контр-вариантное присвоение (Any)=>Boolean = (String)=>Boolean val cmp4 : (Any)=>Boolean = App.strCmp }}
Что нужно знать о Scala:
-
Тип
Any
- это базовый тип для всех типов данных -
Тип
Int, Boolean, String
- это подтипыAny
-
Лямбды то же являются типами, в смысле типы их аргументов и результатов проверяет компилятор
-
Тип лямбды записывается в следующей форме:
(тип_аргументов,через_запятую)=>тип_результата
-
Любой метод с легкостью преобразуется в лямбду
переменная = класс.метод
/переменная = объект.метод
-
val
в Scala, то же что иconst
в JS
В примере мы можем увидеть уже знакомые
Инвариантностьв присвоении переменных:
// Инвариантное присвоение Boolean = Booleanval call1 : Boolean = strCmp("a")// Инвариантное присвоение: (String)=>Boolean = (String)=>Booleanval cmp1 : (String)=>Boolean = App.strCmp;
cmp1 - это переменная содержащая лямбду, при том аргументы и результат которые заданы в определении типа лямбды, полностью совпадают с присваемым значением:
Ожидаемый тип (String)=>BooleanПрисваемый тип (String)=>Boolean
Ко-вариантность
// Ко-вариантное присвоение Any = Booleanval call2 : Any = strCmp("b")// Ко-вариантное присвоение (String)=>Boolean = (Any)=>Booleanval cmp2 : (String)=>Boolean = App.anyCmp
Если в случае присвоения call2, тут все понятно, то может быть непонятно с cmp2.
Ожидаемый тип (String) => BooleanПрисваемый тип (Any) => Boolean
Внезапно отношение String -> к -> Any становится другим - контр-вариантным.
В этом месте, уместно задаться WTF? - Все нормально!
Рассмотрим функции выше
// Функция, на вход String, на выход Boolean, или кратко: (String)=>Boolean def strCmp(a:String):Boolean = a.contains("1")// Функция, на вход String, на выход Boolean, или кратко: (Any)=>Booleandef anyCmp(a:Any):Boolean = true
При вызовеcmp2( "abc"
)
аргумент"abc"
будет передан
вanyCmp(a:Any)
, а по скольку String является под типом
Any, то аргумент не дано преобразовывать и можно передать как
есть.
Иначе говоря вызовanyCmp( "string" )
иanyCmp(
1 )
,anyCmp( true )
- со стороны проверки типов
допустимы операции, по скольку
-
принимаемые аргументы являются подтипами для принимающей стороны, тела функции
-
тип принимаемого аргумента является родительским типом (надтипом) со стороны вызова функции
Т.е. можно при передаче аргументов, действуютко-вариантностьсо стороны принимающей, а со стороны передающейконтр-вариантность.
Еще более наглядно это можно выразить стрелками:
Операция присвоения должна быть ко-вариантна или инвариантна
assign a <- b
А операция вызова функции на оборот - контр-варианта или инвариантна
call a -> b
Этим правилом руководствуются многие компиляторы, и они определяют функции так:
-
Операции передачиаргументовв функции по умолчанию являютсяконтр-вариантны, со стороны вызова функции
-
Операции присвоениярезультатвызова функции по умолчанию являетсяко-вариантны, со стороны вызова функции
Я для себя запомню так
Почему Scala, а не TypeScript
К моему удивлению TypeScript версии 4.2.4 не отрабатывает контр-вариантность в случае функций/лямбд
Вот мой исходник
interface Shape { area():number}class Box implements Shape { width : number height : number constructor( w: number, h: number ){ this.width = w; this.height = h; } area():number { return this.width * this.height }}class Circle implements Shape { radius : number constructor( r: number ){ this.radius = r } area():number { return this.radius * this.radius * Math.PI }}class Foo {}const f1 : (number)=> boolean = a => true;const f2 : (object)=> boolean = a => typeof(a)=='function';const f3 : (any)=>boolean = f1;const f4 : (number)=>boolean = f3;const _f1 : (Box)=>boolean = a => trueconst _f2 : (any)=>boolean = _f1const _f3 : (Shape)=>boolean = _f1
В строкеconst f3 : (any)=>boolean = f1;
и
вconst _f3 : (Shape)=>boolean = _f1
(а так же
предыдущей) компилятор по моей логике должен был ругаться, но он
этого не делал
user@user-Modern-14-A10RB:03:14:17:~/code/blog/itdocs/code-skill/types:> ./node_modules/.bin/tsc -versionVersion 4.2.4user@user-Modern-14-A10RB:03:16:53:~/code/blog/itdocs/code-skill/types:> ./node_modules/.bin/tsc --strictFunctionTypes index.ts user@user-Modern-14-A10RB:03:18:26:~/code/blog/itdocs/code-skill/types:> ./node_modules/.bin/tsc --alwaysStrict index.ts user@user-Modern-14-A10RB:03:19:04:~/code/blog/itdocs/code-skill/types:
Потому пришлось взять язык с более жесткой проверкой типов, надеюсь в новых версиях исправят этот баг.
Ко-вариантность/Контр-вариантность и типы
Еще одна важная оговорка связанная с типами и ООП.
Вариантностьэто не только про иерархию наследования!
В примере о прямоугольнике и круге, я целенаправленно задействовал интерфейсы, хотя обычно используют общий базовый класс.
Ко-Вариантность - это такое качество операции присвоения, когда целевой тип переменнойсовместимс исходным типом значения.
Контр-вариантность- ровно та же ситуация с противоположным знаком.
Тут надо дать пояснение словасовместимость
Пример с кругами и прямоугольниками может быть написан на языке C или ассемблера, или JS ранних версий, в которых нет понятия классов, но при этом оно все так же будет работать.
ООП с наследованием - это всего лишь способ, задать иерархию реальных типов объектов.
В ряде языков был введен запрет на множественное наследование, и это я не могу назвать большим достижением, оно порождает проблемы.
Например я могу выстроить разные наборы иерархий для одних и тех же сущностей:
Например:
-
Человек (общий класс)
-
Национальность (под класс)
-
Социальный статус (под класс)
-
-
или наоборот
-
Человек (общий класс)
-
Пол (под класс)
-
Социальный статус (под класс)
-
-
Это я клоню к тому, что для одной и той же сущности может существовать множество способов квалификации.
И один из подходов - эту сложную сушность (как например человек) можно рассматривать с различных сторон - и вот уже эти стороны можно выделить в виде интерфейсов.
А уже в рамках того или иного интерфейса описывать интересующие свойства и методы для решения практических задач.
Вариантность- это в первую очередь наличие интересующих нах свойств/методов для наших задач. И это механизм контроля со стороны компилятора, для гарантии наличия этих свойств.
Так, например тот или иной объект может быть не только каким либо под классом, но и реализовывать (через интерфейсы) интересующие нас свойства/методы - именно это я понимаю под словомсовместимость.
Далее можно вести разговор о множественном наследовании, трейтах, и прочих прелястях современных языков, но это уже выходит за рамки темы.