Тема вариантов в программировании вызывает кучу сложностей в понимании, по мне это проблема в том, что в качестве объяснения берут не всегда успешные метафоры - контейнеры.
Я надеюсь что может у меня получиться объяснить эту тему с другой стороны используя метафоры “присвоения” в разрезе лямбд.
Зачем вообще эта вариантность нужна ?
В целом без вариантности можно жить и спокойно программировать, это не такая уж архиважная тема, у нас есть множество примеров языков программирования в которых это качество не отображено.
Ко-вариантность это о типах данных и их контроле со стороны компиляторов. И ровно с этого места надо откатиться и сказать о типах данных и зачем это нам нужно.
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 - b
groovy> }
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 )=3
fun1( 'aabc', 'b' )=aac
fun1( [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.ts
index.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.ts
index.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.inv
object 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 = Boolean
val call1 : Boolean = strCmp("a")
// Инвариантное присвоение: (String)=>Boolean = (String)=>Boolean
val cmp1 : (String)=>Boolean = App.strCmp;
cmp1 - это переменная содержащая лямбду, при том аргументы и результат которые заданы в определении типа лямбды, полностью совпадают с присваемым значением:
Ожидаемый тип (String)=>Boolean
Присваемый тип (String)=>Boolean
Ко-вариантность
// Ко-вариантное присвоение Any = Boolean
val call2 : Any = strCmp("b")
// Ко-вариантное присвоение (String)=>Boolean = (Any)=>Boolean
val 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)=>Boolean
def 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 => true
const _f2 : (any)=>boolean = _f1
const _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 -version
Version 4.2.4
user@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 ранних версий, в которых нет понятия классов, но при этом оно все так же будет работать.
ООП с наследованием - это всего лишь способ, задать иерархию реальных типов объектов.
В ряде языков был введен запрет на множественное наследование, и это я не могу назвать большим достижением, оно порождает проблемы.
Например я могу выстроить разные наборы иерархий для одних и тех же сущностей:
Например:
Человек (общий класс)
Национальность (под класс)
Социальный статус (под класс)
или наоборот
Человек (общий класс)
Пол (под класс)
Социальный статус (под класс)
Это я клоню к тому, что для одной и той же сущности может существовать множество способов квалификации.
И один из подходов - эту сложную сушность (как например человек) можно рассматривать с различных сторон - и вот уже эти стороны можно выделить в виде интерфейсов.
А уже в рамках того или иного интерфейса описывать интересующие свойства и методы для решения практических задач.
Вариантность - это в первую очередь наличие интересующих нах свойств/методов для наших задач. И это механизм контроля со стороны компилятора, для гарантии наличия этих свойств.
Так, например тот или иной объект может быть не только каким либо под классом, но и реализовывать (через интерфейсы) интересующие нас свойства/методы - именно это я понимаю под словом совместимость.
Далее можно вести разговор о множественном наследовании, трейтах, и прочих прелястях современных языков, но это уже выходит за рамки темы.