[JavaScript, Программирование, Scala] Ко-вариантность и типы данных

Автор Сообщение
news_bot ®

Стаж: 6 лет 3 месяца
Сообщений: 27286

Создавать темы news_bot ® написал(а)
04-Июн-2021 03:30

Тема вариантов в программировании вызывает кучу сложностей в понимании, по мне это проблема в том, что в качестве объяснения берут не всегда успешные метафоры - контейнеры.Я надеюсь что может у меня получиться объяснить эту тему с другой стороны используя метафоры “присвоения” в разрезе лямбд.Зачем вообще эта вариантность нужна ?В целом без вариантности можно жить и спокойно программировать, это не такая уж архиважная тема, у нас есть множество примеров языков программирования в которых это качество не отображено.Ко-вариантность это о типах данных и их контроле со стороны компиляторов. И ровно с этого места надо откатиться и сказать о типах данных и зачем это нам нужно.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.Тут уместно упомянуть о SOLIDL - 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 ранних версий, в которых нет понятия классов, но при этом оно все так же будет работать.ООП с наследованием - это всего лишь способ, задать иерархию реальных типов объектов.В ряде языков был введен запрет на множественное наследование, и это я не могу назвать большим достижением, оно порождает проблемы.Например я могу выстроить разные наборы иерархий для одних и тех же сущностей:Например:
  • Человек (общий класс)
    • Национальность (под класс)
      • Социальный статус (под класс)
или наоборот
  • Человек (общий класс)
    • Пол (под класс)
      • Социальный статус (под класс)
Это я клоню к тому, что для одной и той же сущности может существовать множество способов квалификации.И один из подходов - эту сложную сушность (как например человек) можно рассматривать с различных сторон - и вот уже эти стороны можно выделить в виде интерфейсов.А уже в рамках того или иного интерфейса описывать интересующие свойства и методы для решения практических задач.Вариантность - это в первую очередь наличие интересующих зачем свойств/методов для наших задач. И это механизм контроля со стороны компилятора, для гарантии наличия этих свойств.Так, например тот или иной объект может быть не только каким либо под классом, но и реализовывать (через интерфейсы) интересующие нас свойства/методы - именно это я понимаю под словом совместимость.Далее можно вести разговор о множественном наследовании, трейтах, и прочих прелястях современных языков, но это уже выходит за рамки темы.
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_javascript, #_programmirovanie (Программирование), #_scala, #_tipy_dannyh (типы данных), #_javascript, #_scala, #_typescript, #_javascript, #_programmirovanie (
Программирование
)
, #_scala
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 19-Май 16:33
Часовой пояс: UTC + 5