Русский
Русский
English
Статистика
Реклама

Ко-вариантность и типы данных

Тема вариантов в программировании вызывает кучу сложностей в понимании, по мне это проблема в том, что в качестве объяснения берут не всегда успешные метафоры - контейнеры.

Я надеюсь что может у меня получиться объяснить эту тему с другой стороны используя метафоры присвоения в разрезе лямбд.

Зачем вообще эта вариантность нужна ?

В целом без вариантности можно жить и спокойно программировать, это не такая уж архиважная тема, у нас есть множество примеров языков программирования в которых это качество не отображено.

Ко-вариантность это о типах данных и их контроле со стороны компиляторов. И ровно с этого места надо откатиться и сказать о типах данных и зачем это нам нужно.

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

Ко-вариантностьобычно объясняют через наследование, и что наследуются все свойства и методы родительского класса - это верно, рассмотрим пару примеров


Ко-вариантностьэто такое качество операции присвоения значения переменной значение переменной другого типа, при котором сохраняются все свойства и операции.

Есть несколько типов чисел и их можно расположить в следующей иерархии:

  1. Натуральные числа N

    • N натуральные числа, включая ноль: {0, 1, 2, 3, }

    • N* натуральные числа без нуля: {1, 2, 3, }

  2. Целые числа Z - обладают знаком (+/-) включают в себя натуральные

  3. Рациональные числа Q - дроби (два целых числа), включают в себя все бесконечное множество Z

  4. Вещественные числа R - это и рациональные и иррациональные числа (например ПИ, e, )

  5. Комплексные числа 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, и возможны несколько сценариев:

  1. переменная a и b - одного типа, тогдаинвариантнаяоперация присвоения

  2. переменная a является базовым типом, а переменная b - подтипом переменной a - тогдако-вариантнаяоперация присвоения

  3. переменная a является подтипом переменной b, а переменная b - базовым (родительским) типом - тогда этоконтр-вариантнаяоперация - и обычно компилятор блокирует такое поведение.

  4. между переменными 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 ранних версий, в которых нет понятия классов, но при этом оно все так же будет работать.

ООП с наследованием - это всего лишь способ, задать иерархию реальных типов объектов.

В ряде языков был введен запрет на множественное наследование, и это я не могу назвать большим достижением, оно порождает проблемы.

Например я могу выстроить разные наборы иерархий для одних и тех же сущностей:

Например:

  • Человек (общий класс)

    • Национальность (под класс)

      • Социальный статус (под класс)

или наоборот

  • Человек (общий класс)

    • Пол (под класс)

      • Социальный статус (под класс)

Это я клоню к тому, что для одной и той же сущности может существовать множество способов квалификации.

И один из подходов - эту сложную сушность (как например человек) можно рассматривать с различных сторон - и вот уже эти стороны можно выделить в виде интерфейсов.

А уже в рамках того или иного интерфейса описывать интересующие свойства и методы для решения практических задач.

Вариантность- это в первую очередь наличие интересующих нах свойств/методов для наших задач. И это механизм контроля со стороны компилятора, для гарантии наличия этих свойств.

Так, например тот или иной объект может быть не только каким либо под классом, но и реализовывать (через интерфейсы) интересующие нас свойства/методы - именно это я понимаю под словомсовместимость.

Далее можно вести разговор о множественном наследовании, трейтах, и прочих прелястях современных языков, но это уже выходит за рамки темы.

Источник: habr.com
К списку статей
Опубликовано: 04.06.2021 02:06:03
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Javascript

Программирование

Scala

Типы данных

Typescript

Категории

Последние комментарии

© 2006-2022, personeltest.ru