23.05.2022

Статистическая типизация. Основные принципы программирования: статическая и динамическая типизация


  • Динами́ческая типиза́ция - приём, широко используемый в языках программирования и языках спецификации, при котором переменная связывается с типом в момент присваивания значения, а не в момент объявления переменной. Таким образом, в различных участках программы одна и та же переменная может принимать значения разных типов. Примеры языков с динамической типизацией - Smalltalk, Python, Objective-C, Ruby, PHP, Perl, JavaScript, Lisp, xBase, Erlang, Visual Basic.

    Противоположный приём - статическая типизация.

    В некоторых языках со слабой динамической типизацией стоит проблема сравнения величин, так, например, PHP имеет операции сравнения «==», «!=» и «===», «!==», где вторая пара операций сравнивает и значения, и типы переменных. Операция «===» даёт true только при полном совпадении, в отличие от «==», который считает верным такое выражение: (1=="1"). Стоит отметить, что это проблема не динамической типизации в целом, а конкретных языков программирования.

Связанные понятия

Язык программи́рования - формальный язык, предназначенный для записи компьютерных программ. Язык программирования определяет набор лексических, синтаксических и семантических правил, определяющих внешний вид программы и действия, которые выполнит исполнитель (обычно - ЭВМ) под её управлением.

Синтаксический сахар (англ. syntactic sugar) в языке программирования - это синтаксические возможности, применение которых не влияет на поведение программы, но делает использование языка более удобным для человека.

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

Расширенная форма Бэкуса - Наура (расширенная Бэкус - Наурова форма (РБНФ)) (англ. Extended Backus–Naur Form (EBNF)) - формальная система определения синтаксиса, в которой одни синтаксические категории последовательно определяются через другие. Используется для описания контекстно-свободных формальных грамматик. Предложена Никлаусом Виртом. Является расширенной переработкой форм Бэкуса - Наура, отличается от БНФ более «ёмкими» конструкциями, позволяющими при той же выразительной способности упростить...

Аппликативное программирование - один из видов декларативного программирования, в котором написание программы состоит в систематическом осуществлении применения одного объекта к другому. Результатом такого применения вновь является объект, который может участвовать в применениях как в роли функции, так и в роли аргумента и так далее. Это делает запись программы математически ясной. Тот факт, что функция обозначается выражением, свидетельствует о возможности использования значений-функций - функциональных...

Конкатенативный язык программирования - это язык программирования, основанный на том, что конкатенация двух фрагментов кода выражает их композицию. В таком языке широко используется неявное указание аргументов функций (см. бесточечное программирование), новые функции определяются как композиция функций, а вместо аппликации применяется конкатенация. Этому подходу противопоставляется аппликативное программирование.

Переме́нная - атрибут физической или абстрактной системы, который может изменять своё, как правило численное, значение. Понятие переменной широко используется в таких областях как математика, естественные науки, техника и программирование. Примерами переменных могут служить: температура воздуха, параметр функции и многое другое.

Синтакси́ческий ана́лиз (или разбор, жарг. па́рсинг ← англ. parsing) в лингвистике и информатике - процесс сопоставления линейной последовательности лексем (слов, токенов) естественного или формального языка с его формальной грамматикой. Результатом обычно является дерево разбора (синтаксическое дерево). Обычно применяется совместно с лексическим анализом.

Обобщённый алгебраический тип да́нных (англ. generalized algebraic data type, GADT) - один из видов алгебраических типов данных, который характеризуется тем, что его конструкторы могут возвращать значения не своего типа, связанного с ним. Сконструированы под влиянием работ об индуктивных семействах в среде исследователей зависимых типов.

Сема́нтика в программировании - дисциплина, изучающая формализации значений конструкций языков программирования посредством построения их формальных математических моделей. В качестве инструментов построения таких моделей могут использоваться различные средства, например, математическая логика, λ-исчисление, теория множеств, теория категорий, теория моделей, универсальная алгебра. Формализация семантики языка программирования может использоваться как для описания языка, определения свойств языка...

Объе́ктно-ориенти́рованное программи́рование (ООП) - методология программирования, основанная на представлении программы в виде совокупности объектов, каждый из которых является экземпляром определённого класса, а классы образуют иерархию наследования.

Динами́ческая переме́нная - переменная в программе, место в оперативной памяти под которую выделяется во время выполнения программы. По сути, она является участком памяти, выделенным системой программе для конкретных целей во время работы программы. Этим она отличается от глобальной статической переменной - участка памяти, выделенного системой программе для конкретных целей перед началом работы программы. Динамическая переменная - один из классов памяти переменной.



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



Тип - это коллекция возможных значений. Целое число может обладать значениями 0, 1, 2, 3 и так далее. Булево может быть истиной или ложью. Можно придумать свой тип, например, тип "ДайПять", в котором возможны значения "дай" и "5", и больше ничего. Это не строка и не число, это новый, отдельный тип.


Статически типизированные языки ограничивают типы переменных: язык программирования может знать, например, что x - это Integer. В этом случае программисту запрещается делать x = true , это будет некорректный код. Компилятор откажется компилировать его, так что мы не сможем даже запустить такой код. Другой статически типизированный язык может обладать другими выразительными возможностями, и никакая из популярных систем типов не способна выразить наш тип ДайПять (но многие могут выразить другие, более изощренные идеи).


Динамически типизированные языки помечают значения типами: язык знает, что 1 это integer, 2 это integer, но он не может знать, что переменная x всегда содержит integer.


Среда выполнения языка проверяет эти метки в разные моменты времени. Если мы попробуем сложить два значения, то она может проверить, являются ли они числами, строками или массивами. Потом она сложит эти значения, склеит их или выдаст ошибку, в зависимости от типа.

Статически типизированные языки

Статические языки проверяют типы в программе во время компиляции, еще до запуска программы. Любая программа, в которой типы нарушают правила языка, считается некорректной. Например, большинство статических языков отклонит выражение "a" + 1 (язык Си - это исключение из этого правила). Компилятор знает, что "a" - это строка, а 1 - это целое число, и что + работает только когда левая и правая часть относятся к одному типу. Так что ему не нужно запускать программу чтобы понять, что существует проблема. Каждое выражение в статически типизированном языке относится к определенному типу, который можно определить без запуска кода.


Многие статически типизированные языки требуют обозначать тип. Функция в Java public int add(int x, int y) принимает два целых числа и возвращает третье целое число. Другие статически типизированные языки могут определить тип автоматически. Та же самая функция сложения в Haskell выглядит так: add x y = x + y . Мы не сообщаем языку типы, но он может определить их сам, потому что знает, что + работает только на числах, так что x и y должны быть числами, значит функция add принимает два числа как аргументы.


Это не уменьшает "статичность" системы типов. Система типов в Haskell знаменита своей статичностью, строгостью и мощностью, и в по всем этим фронтам Haskell опережает Java.

Динамически типизированные языки

Динамически типизированные языки не требуют указывать тип, но и не определяют его сами. Типы переменных неизвестны до того момента, когда у них есть конкретные значения при запуске. Например, функция в Python


def f(x, y): return x + y

может складывать два целых числа, склеивать строки, списки и так далее, и мы не можем понять, что именно происходит, пока не запустим программу. Возможно, в какой-то момент функцию f вызовут с двумя строками, и с двумя числами в другой момент. В таком случае x и y будут содержать значения разных типов в разное время. Поэтому говорят, что значения в динамических языках обладают типом, но переменные и функции - нет. Значение 1 это определенно integer, но x и y могут быть чем угодно.

Сравнение

Большинство динамических языков выдадут ошибку, если типы используются некорректно (JavaScript - известное исключение; он пытается вернуть значение для любого выражения, даже когда оно не имеет смысла). При использовании динамически типизированных языков даже простая ошибка вида "a" + 1 может возникнуть в боевом окружении. Статические языки предотвращают такие ошибки, но, конечно, степень предотвращения зависит от мощности системы типов.


Статические и динамические языки построены на фундаментально разных идеях о корректности программ. В динамическом языке "a" + 1 это корректная программа: код будет запущен и появится ошибка в среде исполнения. Однако, в большинстве статически типизированных языков выражение "a" + 1 - это не программа : она не будет скомпилирована и не будет запущена. Это некорректный код, так же, как набор случайных символов!&%^@*&%^@* - это некорректный код. Это дополнительное понятие о корректности и некорректности не имеет эквивалента в динамических языках.

Сильная и слабая типизация

Понятия "сильный" и "слабый" - очень неоднозначные. Вот некоторые примеры их использования:

    Иногда "сильный" означает "статический".
    Тут все просто, но лучше использовать термин "статический", потому что большинство используют и понимают его.

    Иногда "сильный" означает "не делает неявное преобразование типов".
    Например, JavaScript позволяет написать "a" + 1 , что можно назвать "слабой типизацией". Но почти все языки предоставляют тот или иной уровень неявного преобразования, которое позволяет автоматически переходить от целых чисел к числам с плавающей запятой вроде 1 + 1.1 . В реальности, большинство людей используют слово "сильный" для определения границы между приемлемым и неприемлемым преобразованием. Нет какой-то общепринятой границы, они все неточные и зависят от мнения конкретного человека.

    Иногда "сильный" означает, что невозможно обойти строгие правила типизации в языке.

  • Иногда "сильный" означает безопасный для памяти (memory-safe).
    Си - это пример небезопасного для памяти языка. Если xs - это массив четырех чисел, то Си с радостью выполнит код xs или xs , возвращая какое-то значение из памяти, которая находится сразу за xs .

Давайте остановимся. Вот как некоторые языки отвечают этим определениям. Как можно заметить, только Haskell последовательно "сильный" по всем параметрам. Большинство языков не такие четкие.



("Когда как" в колонке "Неявные преобразования" означает, что разделение между сильным и слабым зависит от того, какие преобразования мы считаем приемлемыми).


Зачастую термины "сильный" и "слабый" относятся к неопределенной комбинации разных определений выше, и других, не показанных здесь определений. Весь этот беспорядок делает слова "сильный" и "слабый" практически бессмысленными. Когда хочется использовать эти термины, то лучше описать, что конкретно имеется ввиду. Например, можно сказать, что "JavaScript возвращает значение, когда складывается строка с числом, но Python возвращает ошибку". В таком случае мы не будем тратить свои силы на попытки прийти к соглашению о множестве значений слова "сильный". Или, еще хуже: придем к неразрешенному непониманию из-за терминологии.


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



Сильная типизация: Система типов, которую я люблю и с которой мне комфортно.

Слабая типизация: Система типов, которая беспокоит меня или с которой мне не комфортно.

Постепенная типизация (gradual typing)

Можно ли добавить статические типы в динамические языки? В некоторых случаях - да. В других это сложно или невозможно. Самая очевидная проблема - это eval и другие похожие возможности динамических языков. Выполнение 1 + eval("2") в Python дает 3. Но что даст 1 + eval(read_from_the_network()) ? Это зависит от того, что в сети на момент выполнения. Если получим число, то выражение корректно. Если строку, то нет. Невозможно узнать до запуска, так что невозможно анализировать тип статически.


Неудовлетворительное решение на практике - это задать выражению eval() тип Any, что напоминает Object в некоторых объектно-ориентированных языках программирования или интерфейс interface {} в Go: это тип, которому удовлетворяет любое значение.


Значения типа Any не ограничены ничем, так что исчезает возможность системы типов помогать нам в коде с eval. Языки, в которых есть и eval и система типов, должны отказываться от безопасности типов при каждом использовании eval .


В некоторых языках есть опциональная или постепенная типизация (gradual typing): они динамические по умолчанию, но позволяют добавлять некоторые статические аннотации. В Python недавно добавили опциональные типы; TypeScript - это надстройка над JavaScript, в котором есть опциональные типы; Flow производит статический анализ старого доброго кода на JavaScript.


Эти языки предоставляют некоторые преимущества статической типизации, но они никогда не дадут абсолютной гарантии, как по-настоящему статические языки. Некоторые функции будут статически типизированными, а некоторые будут динамически типизированными. Программисту всегда нужно знать и опасаться разницы.

Компиляция статически типизированного кода

Когда происходит компиляция статически типизированного кода, сначала проверяется синтаксис, как в любом компиляторе. Потом проверяются типы. Это означает, что статический язык сначала может пожаловаться на одну синтаксическую ошибку, а после ее исправления пожаловаться на 100 ошибок типизации. Исправление синтаксической ошибки не создало эти 100 ошибок типизации. Компилятор просто не имел возможности обнаружить ошибки типов, пока не был исправлен синтаксис.


Компиляторы статических языков обычно могут генерировать более быстрый код, чем компиляторы динамических. Например, если компилятор знает, что функция add принимает целые числа, то он может использовать нативную инструкцию ADD центрального процессора. Динамический язык будет проверять тип при выполнении, выбирая один из множества функций add в зависимости от типов (складываем integers или floats или склеиваем строки или, может быть, списки?) Или нужно решить, что возникла ошибка и типы не соответствуют друг другу. Все эти проверки занимают время. В динамических языках используются разные трюки для оптимизации, например JIT-компиляция (just-in-time), где код перекомпилируется при выполнении после получения всей необходимой о типах информации. Однако, никакой динамический язык не может сравниться по скоростью с аккуратно написанным статическим кодом на языке вроде Rust.

Аргументы в пользу статических и динамических типов

Сторонники статической системы типов указывают на то, что без системы типов простые ошибки могут привести к проблемам в продакшене. Это, конечно же, правда. Любой, кто использовал динамический язык, испытал это на себе.


Сторонники динамических языков указывают на то, что на таких языках, кажется, легче писать код. Это определенно справедливо для некоторых видов кода, который мы время от времени пишем, как, например, тот код с eval . Это спорное решение для регулярной работы, и здесь имеет смысл вспомнить неопределенное слово "легко". Рич Хики отлично рассказал про слово "легко", и его связь со словом "просто". Посмотрев этот доклад вы поймете, что не легко правильно использовать слово "легко". Опасайтесь "легкости".


Плюсы и минусы статических и динамических систем типизации все еще плохо изучены, но они определенно зависят от языка и конкретной решаемой задачи.


JavaScript пытается продолжить работу, даже если это означает бессмысленную конвертацию (вроде "a" + 1 , дающее "a1"). Python в свою очередь старается быть консервативным и часто возвращает ошибки, как в случае с "a" + 1 .


Существуют разные подходы с разными уровнями безопасности, но Python и JavaScript оба являются динамически типизированными языками.



Haskell же не позволит сложить integer и float без явного преобразования перед этим. Си и Haskell оба являются статически типизированными, не смотря на такие большие отличия.


Есть множество вариаций динамических и статических языков. Любое безоговорочное высказывание вида "статические языки лучше, чем динамические, когда дело касается Х" - это почти гарантированно ерунда. Это может быть правдой в случае конкретных языков, но тогда лучше сказать "Haskell лучше, чем Python когда дело касается Х".

Разнообразие статических систем типизации

Давайте взглянем на два знаменитых примера статически типизированных языков: Go и Haskell. В системе типизации Go нет обобщенных типов, типов с "параметрами" от других типов. Например, можно создать свой тип для списков MyList, который может хранить любые нужные нам данные. Мы хотим иметь возможность создавать MyList целых чисел, MyList строк и так далее, не меняя исходный код MyList. Компилятор должен следить за типизацией: если есть MyList целых чисел, и мы случайно добавляем туда строку, то компилятор должен отклонить программу.


Go специально был спроектирован таким образом, чтобы невозможно было задавать типы вроде MyList. Лучшее, что возможно сделать, это создать MyList "пустых интерфейсов": MyList может содержать объекты, но компилятор просто не знает их тип. Когда мы достаем объекты из MyList, нам нужно сообщить компилятору их тип. Если мы говорим "Я достаю строку", но в реальности значение - это число, то будет ошибка исполнения, как в случае с динамическими языками.


В Go также нет множества других возможностей, которые присутствуют в современных статически типизированных языках (или даже в некоторых системах 1970-х годов). У создателей Go были свои причины для этих решений, но мнение людей со стороны по этому поводу иногда может звучать резко.


Теперь давайте сравним с Haskell, который обладает очень мощной системой типов. Если задать тип MyList, то тип "списка чисел" это просто MyList Integer . Haskell не даст нам случайно добавить строку в список, и удостоверится, что мы не положим элемент из списка в строковую переменную.


Haskell может выражать намного более сложные идеи напрямую типами. Например, Num a => MyList a означает "MyList значений, которые относятся к одному типу чисел". Это может быть список integer"ов, float"ов или десятичных чисел с фиксированной точностью, но это определенно никогда не будет списком строк, что проверяется при компиляции.


Можно написать функцию add, которая работает с любыми численными типами. У этой функции будет тип Num a => (a -> a -> a) . Это означает:

  • a может быть любым численным типом (Num a =>).
  • Функция принимает два аргумента типа a и возвращает тип a (a -> a -> a).

Последний пример. Если тип функции это String -> String , то она принимает строку и возвращает строку. Но если это String -> IO String , то она также совершает какой-то ввод/вывод. Это может быть обращение к диску, к сети, чтение из терминала и так далее.


Если у функции в типе нет IO, то мы знаем, что она не совершает никаких операций ввода/вывода. В веб-приложении, к примеру, можно понять, изменяет ли функция базу данных, просто взглянув на ее тип. Никакие динамические и почти никакие статические языки не способы на такое. Это особенность языков с самой мощной системой типизации.


В большинстве языков нам пришлось бы разбираться с функцией и всеми функциями, которые оттуда вызываются, и так далее, в попытках найти что-то, изменяющее базу данных. Это утомительный процесс, в котором легко допустить ошибку. А система типов Haskell может ответить на этот вопрос просто и гарантированно.


Сравните эту мощность с Go, который не способен выразить простую идею MyList, не говоря уже о "функции, которая принимает два аргумента, и они оба численные и одного типа, и которая делает ввод/вывод".


Подход Go упрощает написание инструментов для программирования на Go (в частности, реализация компилятора может быть простой). К тому же, требуется изучить меньше концепций. Как эти преимущества сравнимы со значительными ограничениями - субъективный вопрос. Однако, нельзя поспорить, что Haskell сложнее изучить, чем Go, и что система типов в Haskell намного мощнее, и что Haskell может предотвратить намного больше типов багов при компиляции.


Go и Haskell настолько разные языки, что их группировка в один класс "статических языков" может вводить в заблуждение, не смотря на то, что термин используется корректно. Если сравнивать практические преимущества безопасности, то Go ближе к динамических языкам, нежели к Haskell"у.


С другой стороны, некоторые динамические языки безопаснее, чем некоторые статические языки. (Python в целом считается намного безопаснее, чем Си). Когда хочется делать обобщения о статических или динамических языках как группах, то не забывайте об огромном количестве отличий между языками.

Конкретные примеры отличия в возможностях систем типизации

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


В Go можно сказать "функция add принимает два integer"а и возвращает integer":


func add(x int, y int) int { return x + y }

В Haskell можно сказать "функция принимает любой численный тип и возвращает число того же типа":


f:: Num a => a -> a -> a add x y = x + y

В Idris можно сказать "функция принимает два integer"а и возвращает integer, но первый аргумент должен быть меньше второго аргумента":


add: (x: Nat) -> (y: Nat) -> {auto smaller: LT x y} -> Nat add x y = x + y

Если попытаться вызвать функцию add 2 1 , где первый аргумент больше второго, то компилятор отклонит программу во время компиляции . Невозможно написать программу, где первый аргумент больше второго. Редкий язык обладает такой возможностью. В большинстве языков такая проверка происходит при выполнении: мы бы написали что-то вроде if x >= y: raise SomeError() .


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

Системы типизации некоторых статических языков

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

  • C (1972), Go (2009) : Эти системы совсем не мощные, без поддержки обобщенных типов. Невозможно задать тип MyList, который бы означал "список целых чисел", "список строк" и т.д. Вместо этого придется делать "список необозначенных значений". Программист должен вручную сообщать "это список строк" каждый раз, когда строка извлекается из списка, и это может привести к ошибке при исполнении.
  • Java (1995), C# (2000) : Оба языка поддерживают обобщенные типы, так что можно сказать MyList и получить список строк, о котором компилятор знает и может следить за соблюдением правил типов. Элементы из списка будут обладать типом String, компилятор будет форсировать правила при компиляции как обычно, так что ошибки при исполнении менее вероятны.
  • Haskell (1990), Rust (2010), Swift (2014) : Все эти языки обладают несколькими продвинутыми возможностями, в том числе обобщенными типами, алгебраическими типами данных (ADTs), и классами типов или чем-то похожим (типы классов, признаки (traits) и протоколы, соответственно). Rust и Swift более популярны, чем Haskell, и их продвигают крупные организации (Mozilla и Apple, соответственно).
  • Agda (2007), Idris (2011) : Эти языки поддерживают зависимые типы, позволяя создавать типы вроде "функция, которая принимает два целых числа х и y, где y больше, чем x". Даже ограничение "y больше, чем x" форсируется при компиляции. При выполнении y никогда не будет меньше или равно x, что бы ни случилось. Очень тонкие, но важные свойства системы могут быть проверены статически в этих языках. Их изучает очень мало программистов, но эти языки вызывают у них огромный энтузиазм.

Наблюдается явное движение в сторону более мощных систем типизации, особенно если судить по популярности языков, а не по простому факту существования языков. Известное исключение - это Go, который объясняет, почему многие сторонники статических языков считают его шагом назад.


Группа два (Java и C#) - это мэйнстримовые языки, зрелые и широко используемые.


Группа три находится на пороге входа в мэйнстрим, с большой поддержкой со стороны Mozilla (Rust) и Apple (Swift).


Группа четыре (Idris and Agda) далеки от мэйнстрима, но это может измениться со временем. Языки группы три были далеко от мэйнстрима еще десять лет назад.

Хотя возможны и промежуточные варианты, здесь представлены два главных подхода:

  • Динамическая типизация : ждать момента выполнения каждого вызова и тогда принимать решение.
  • Статическая типизация : с учетом набора правил определить по исходному тексту, возможны ли нарушения типов при выполнении. Система выполняется, если правила гарантируют отсутствие ошибок.

Эти термины легко объяснимы: при динамической типизации проверка типов происходит во время работы системы (динамически), а при статической типизации проверка выполняется над текстом статически (до выполнения).

Статическая типизация предполагает автоматическую проверку, возлагаемую, как правило, на компилятор. В итоге имеем простое определение:

Определение: статически типизированный язык

ОО-язык статически типизирован, если он поставляется с набором согласованных правил, проверяемых компилятором, соблюдение которых гарантирует, что выполнение системы не приведет к нарушению типов.

В литературе встречается термин " сильная типизация" (strong ). Он соответствует ультимативной природе определения, требующей полного отсутствия нарушения типов. Возможны и слабые (weak ) формы статической типизации , при которых правила устраняют определенные нарушения, не ликвидируя их целиком. В этом смысле некоторые ОО-языки являются статически слабо типизированными. Мы будем бороться за наиболее сильную типизацию.

В динамически типизированных языках , известных как нетипизированные, отсутствуют объявления типов, а к сущностям в период выполнения могут присоединяться любые значения. Статическая проверка типов в них невозможна.

Правила типизации

Наша ОО-нотация является статически типизированной. Ее правила типов были введены в предыдущих лекциях и сводятся к трем простым требованиям.

  • При объявлении каждой сущности или функции должен задаваться ее тип, например, acc : ACCOUNT . Каждая подпрограмма имеет 0 или более формальных аргументов, тип которых должен быть задан, например: put (x: G; i: INTEGER) .
  • В любом присваивании x:= y и при любом вызове подпрограммы, в котором y - это фактический аргумент для формального аргумента x , тип источника y должен быть совместим с типом цели x . Определение совместимости основано на наследовании: B совместим с A , если является его потомком, - дополненное правилами для родовых параметров (см. "Введение в наследование").
  • Вызов x.f (arg) требует, чтобы f был компонентом базового класса для типа цели x , и f должен быть экспортирован классу, в котором появляется вызов (см. 14.3).

Реализм

Хотя определение статически типизированного языка дано совершенно точно, его недостаточно, - необходимы неформальные критерии при создании правил типизации. Рассмотрим два крайних случая.

  • Совершенно корректный язык , в котором каждая синтаксически правильная система корректна и в отношении типов. Правила описания типов не нужны. Такие языки существуют (представьте себе польскую запись выражения со сложением и вычитанием целых чисел). К сожалению, ни один реальный универсальный язык не отвечает этому критерию.
  • Совершенно некорректный язык , который легко создать, взяв любой существующий язык и добавив правило типизации, делающее любую систему некорректной. По определению, этот язык типизирован: так как нет систем, соответствующих правилам, то ни одна система не вызовет нарушения типов.

Можно сказать, что языки первого типа пригодны , но бесполезны , вторые, возможно, полезны, но не пригодны.

На практике необходима система типов, пригодная и полезная одновременно: достаточно мощная для реализации потребностей вычислений и достаточно удобная, не заставляющая нас идти на усложнения для удовлетворения правил типизации.

Будем говорить, что язык реалистичен , если он пригоден к применению и полезен на практике. В отличие от определения статической типизации , дающего безапелляционный ответ на вопрос: " Типизирован ли язык X статически ?", определение реализма отчасти субъективно.

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

Пессимизм

Статическая типизация приводит по своей природе к "пессимистической" политике. Попытка дать гарантию, что все вычисления не приводят к отказам , отвергает вычисления, которые могли бы закончиться без ошибок .

Рассмотрим обычный, необъектный, Pascal-подобный язык с различными типами REAL и INTEGER . При описании n: INTEGER; r: Real оператор n:= r будет отклонен, как нарушающий правила. Так, компилятор отвергнет все нижеследующие операторы:

n:= 0.0 [A] n:= 1.0 [B] n:= -3.67 [C] n:= 3.67 - 3.67 [D]

Если мы разрешим их выполнение, то увидим, что [A] будет работать всегда, так как любая система счисления имеет точное представление вещественного числа 0,0, недвусмысленно переводимое в 0 целых. [B] почти наверняка также будет работать. Результат действия [C] не очевиден (хотим ли мы получить итог округлением или отбрасыванием дробной части?). [D] справится со своей задачей, как и оператор:

if n ^ 2 < 0 then n:= 3.67 end [E]

куда входит недостижимое присваивание (n ^ 2 - это квадрат числа n ). После замены n ^ 2 на n правильный результат даст только ряд запусков. Присваивание n большого вещественного значения, не представимого целым, приведет к отказу.

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

Вопрос не в том, будем ли мы пессимистами, а в том, насколько пессимистичными мы можем позволить себе быть. Вернемся к требованию реализма : если правила типов настолько пессимистичны, что препятствуют простоте записи вычислений, мы их отвергнем. Но если достижение безопасности типов достигается небольшой потерей выразительной силы, мы примем их. Например, в среде разработки, предоставляющей функции округления и выделения целой части - round и truncate , оператор n:= r считается некорректным справедливо, поскольку заставляет вас явно записать преобразование вещественного числа в целое, вместо использования двусмысленных преобразований по умолчанию.

Статическая типизация: как и почему

Хотя преимущества статической типизации очевидны, неплохо поговорить о них еще раз.

Преимущества

Причины применения статической типизации в объектной технологии мы перечислили в начале лекции. Это надежность, простота понимания и эффективность.

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

Раннее выявление ошибок важно еще и потому, что чем дольше мы будем откладывать их поиск, тем сильнее вырастут издержки на исправление. Это свойство, интуитивно понятное всем программистам-профессионалам, количественно подтверждают широко известные работы Бема (Boehm). Зависимость издержек на исправление от времени отыскания ошибок приведена на графике, построенном по данным ряда больших промышленных проектов и проведенных экспериментов с небольшим управляемым проектом:


Рис. 17.1.

Читабельность или Простота понимания ( readability ) имеет свои преимущества. Во всех примерах этой книги появление типа у сущности дает читателю информацию о ее назначении. Читабельность крайне важна на этапе сопровождения.

Наконец, эффективность может определять успех или отказ от объектной технологии на практике. В отсутствие статической типизации на выполнение x.f (arg) может уйти сколько угодно времени. Причина этого в том, что на этапе выполнения, не найдя f в базовом классе цели x , поиск будет продолжен у ее потомков, а это верная дорога к неэффективности. Снять остроту проблемы можно, улучшив поиск компонента по иерархии. Авторы языка Self провели большую работу, стремясь генерировать лучший код для языка с динамической типизацией. Но именно статическая типизация позволила такому ОО-продукту приблизиться или сравняться по эффективности с традиционным ПО.

Ключом к статической типизации является уже высказанная идея о том, что компилятор, генерирующий код для конструкции x.f (arg) , знает тип x . Из-за полиморфизма нет возможности однозначно определить подходящую версию компонента f . Но объявление сужает множество возможных типов, позволяя компилятору построить таблицу, обеспечивающую доступ к правильному f с минимальными издержками, - с ограниченной константой сложностью доступа. Дополнительно выполняемые оптимизации статического связывания (static binding) и подстановки (inlining) - также облегчаются благодаря статической типизации , полностью устраняя издержки в тех случаях, когда они применимы.

Аргументы в пользу динамической типизации

Несмотря на все это, динамическая типизация не теряет своих приверженцев, в частности, среди Smalltalk-программистов. Их аргументы основаны прежде всего на реализме, речь о котором шла выше. Они уверены, что статическая типизация чересчур ограничивает их, не давая им свободно выражать свои творческие идеи, называя иногда ее "поясом целомудрия".

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

Типизация: слагаемые успеха

Каковы механизмы реалистичной статической типизации ? Все они введены в предыдущих лекциях, а потому нам остается лишь кратко о них напомнить. Их совместное перечисление показывает согласованность и мощь их объединения.

Наша система типов полностью основана на понятии класса . Классами являются даже такие базовые типы, как INTEGER , а стало быть, нам не нужны особые правила описания предопределенных типов. (В этом наша нотация отличается от "гибридных" языков наподобие Object Pascal , Java и C++, где система типов старых языков сочетается с объектной технологией, основанной на классах.)

Развернутые типы дают нам больше гибкости, допуская типы, чьи значения обозначают объекты, наряду с типами, чьи значения обозначают ссылки.

Решающее слово в создании гибкой системы типов принадлежит наследованию и связанному с ним понятию совместимости . Тем самым преодолевается главное ограничение классических типизированных языков, к примеру, Pascal и Ada, в которых оператор x:= y требует, чтобы тип x и y был одинаковым. Это правило слишком строго: оно запрещает использовать сущности, которые могут обозначать объекты взаимосвязанных типов (SAVINGS_ACCOUNT и CHECKING_ACCOUNT ). При наследовании мы требуем лишь совместимости типа y с типом x , например, x имеет тип ACCOUNT , y - SAVINGS_ACCOUNT , и второй класс - наследник первого.

На практике статически типизированный язык нуждается в поддержке множественного наследования . Известны принципиальные обвинения статической типизации в том, что она не дает возможность по-разному интерпретировать объекты. Так, объект DOCUMENT (документ) может передаваться по сети, а потому нуждается в наличия компонентов, связанных с типом MESSAGE (сообщение). Но эта критика верна только для языков, ограниченных единичным наследованием .


Рис. 17.2.

Универсальность необходима, например, для описания гибких, но безопасных контейнерных структур данных (например class LIST [G] .. .). Не будь этого механизма, статическая типизация потребовала бы объявления разных классов для списков, отличающихся типом элементов.

В ряде случаев универсальность требуется ограничить , что позволяет использовать операции, применимые лишь к сущностям родового типа. Если родовой класс SORTABLE_LIST поддерживает сортировку, он требует от сущностей типа G , где G - родовой параметр, наличия операции сравнения. Это достигается связыванием с G класса, задающего родовое ограничение, - COMPARABLE :

class SORTABLE_LIST ...

Любой фактический родовой параметр SORTABLE_LIST должен быть потомком класса COMPARABLE , имеющего необходимый компонент.

Еще один обязательный механизм - попытка присваивания - организует доступ к тем объектам, типом которых ПО не управляет. Если y - это объект базы данных или объект, полученный через сеть, то оператор x ?= y присвоит x значение y , если y имеет совместимый тип, или, если это не так, даст x значение Void .

Утверждения , связанные, как часть идеи Проектирования по Контракту, с классами и их компонентами в форме предусловий, постусловий и инвариантов класса, дают возможность описывать семантические ограничения, которые не охватываются спецификацией типа . В таких языках, как Pascal и Ada, есть типы-диапазоны, способные ограничить значения сущности, к примеру, интервалом от 10 до 20, однако, применяя их, вам не удастся добиться того, чтобы значение i являлось отрицательным, всегда вдвое превышая j . На помощь приходят инварианты классов, призванные точно отражать вводимые ограничения, какими бы сложными они не были.

Закрепленные объявления нужны для того, чтобы на практике избегать лавинного дублирования кода. Объявляя y: like x , вы получаете гарантию того, что y будет меняться вслед за любыми повторными объявлениями типа x у потомка. В отсутствие этого механизма разработчики беспрестанно занимались бы повторными объявлениями, стремясь сохранить соответствие различных типов.

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

При разработке программных систем на деле необходимо еще одно свойство, присущее самой среде разработки - быстрая, возрастающая (fast incremental) перекомпиляция . Когда вы пишите или модифицируете систему, хотелось бы как можно скорее увидеть эффект изменений. При статической типизации вы должны дать компилятору время на перепроверку типов. Традиционные подпрограммы компиляции требуют повторной трансляции всей системы (и ее сборки ), и этот процесс может быть мучительно долгим, особенно с переходом к системам большого масштаба. Это явление стало аргументом в пользу интерпретирующих систем, таких как ранние среды Lisp или Smalltalk, запускавшие систему практически без обработки, не выполняя проверку типов. Сейчас этот аргумент позабыт. Хороший современный компилятор определяет, как изменился код с момента последней компиляции, и обрабатывает лишь найденные изменения.

"Типизирована ли кроха"?

Наша цель - строгая статическая типизация . Именно поэтому мы и должны избегать любых лазеек в нашей "игре по правилам", по крайней мере, точно их идентифицировать, если они существуют.

Самой распространенной лазейкой в статически типизированных языках является наличие преобразований, меняющих тип сущности . В C и производных от него языках их называют "приведением типа" или кастингом ( cast ). Запись (OTHER_TYPE) x указывает на то, что значение x воспринимается компилятором, как имеющее тип OTHER_TYPE , при соблюдении некоторых ограничений на возможные типы.

Подобные механизмы обходят ограничения проверки типов. Приведение широко распространено при программировании на языке C, включая диалект ANSI C. Даже в языке C++ приведение типов, хотя и не столь частое, остается привычным и, возможно, необходимым делом.

Придерживаться правил статической типизации не так просто, если в любой момент их можно обойти путем приведения.

Типизация и связывание

Хотя как читатель этой книги вы наверняка отличите статическую типизацию от статического связывания , есть люди, которым подобное не под силу. Отчасти это может быть связано с влиянием языка Smalltalk, отстаивающего динамический подход к обеим задачам и способного сформировать неверное представление, будто они имеют одинаковое решение. (Мы же в своей книге утверждаем, что для создания надежных и гибких программ желательно объединить статическую типизацию и динамическое связывание.)

Как типизация, так и связывание имеют дело с семантикой Базисной Конструкции x.f (arg) , но отвечают на два разных вопроса:

Типизация и связывание

  • Вопрос о типизации : когда мы должны точно знать, что во время выполнения появится операция, соответствующая f , применимая к объекту, присоединенному к сущности x (с параметром arg )?
  • Вопрос о связывании : когда мы должны знать, какую операцию инициирует данный вызов?

Типизация отвечает на вопрос о наличии как минимум одной операции, связывание отвечает за выбор нужной .

В рамках объектного подхода:

  • проблема, возникающая при типизации, связана с полиморфизмом : поскольку x во время выполнения может обозначать объекты нескольких различных типов, мы должны быть уверены, что операция, представляющая f , доступна в каждом из этих случаев;
  • проблема связывания вызвана повторными объявлениями : так как класс может менять наследуемые компоненты, то могут найтись две или более операции, претендующие на то, чтобы представлять f в данном вызове.

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

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

Отметим своеобразие языка C++, поддерживающего статическую типизацию, хотя и не строгую ввиду наличия приведения типов, статическое связывание (по умолчанию), динамическое связывание при явном указании виртуальных (virtual ) объявлений.

Причина выбора статической типизации и динамического связывания очевидна. Первый вопрос: "Когда мы будем знать о существовании компонентов?" - предполагает статический ответ: " Чем раньше, тем лучше ", что означает: во время компиляции. Второй вопрос: "Какой из компонентов использовать?" предполагает динамический ответ: " тот, который нужен ", - соответствующий динамическому типу объекта, определяемому во время выполнения. Это единственно приемлемое решение, если статическое и динамическое связывание дает различные результаты.

При статической типизации компилятор не отклонит вызов, если можно гарантировать, что при выполнении программы к сущности my_aircraft будет присоединен объект, поставляемый с компонентом, соответствующим lower_landing_gear . Базисная техника получения гарантий проста: при обязательном объявлении my_aircraft требуется, чтобы базовый класс его типа включал такой компонент. Поэтому my_aircraft не может быть объявлен как AIRCRAFT , так как последний не имеет lower_landing_gear на этом уровне; вертолеты, по крайней мере в нашем примере, выпускать шасси не умеют. Если же мы объявим сущность как PLANE , - класс, содержащий требуемый компонент, - все будет в порядке.

Динамическая типизация в стиле Smalltalk требует дождаться вызова, и в момент его выполнения проверить наличие нужного компонента. Такое поведение возможно для прототипов и экспериментальных разработок, но недопустимо для промышленных систем - в момент полета поздно спрашивать, есть ли у вас шасси .

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



Тип - это коллекция возможных значений. Целое число может обладать значениями 0, 1, 2, 3 и так далее. Булево может быть истиной или ложью. Можно придумать свой тип, например, тип "ДайПять", в котором возможны значения "дай" и "5", и больше ничего. Это не строка и не число, это новый, отдельный тип.


Статически типизированные языки ограничивают типы переменных: язык программирования может знать, например, что x - это Integer. В этом случае программисту запрещается делать x = true , это будет некорректный код. Компилятор откажется компилировать его, так что мы не сможем даже запустить такой код. Другой статически типизированный язык может обладать другими выразительными возможностями, и никакая из популярных систем типов не способна выразить наш тип ДайПять (но многие могут выразить другие, более изощренные идеи).


Динамически типизированные языки помечают значения типами: язык знает, что 1 это integer, 2 это integer, но он не может знать, что переменная x всегда содержит integer.


Среда выполнения языка проверяет эти метки в разные моменты времени. Если мы попробуем сложить два значения, то она может проверить, являются ли они числами, строками или массивами. Потом она сложит эти значения, склеит их или выдаст ошибку, в зависимости от типа.

Статически типизированные языки

Статические языки проверяют типы в программе во время компиляции, еще до запуска программы. Любая программа, в которой типы нарушают правила языка, считается некорректной. Например, большинство статических языков отклонит выражение "a" + 1 (язык Си - это исключение из этого правила). Компилятор знает, что "a" - это строка, а 1 - это целое число, и что + работает только когда левая и правая часть относятся к одному типу. Так что ему не нужно запускать программу чтобы понять, что существует проблема. Каждое выражение в статически типизированном языке относится к определенному типу, который можно определить без запуска кода.


Многие статически типизированные языки требуют обозначать тип. Функция в Java public int add(int x, int y) принимает два целых числа и возвращает третье целое число. Другие статически типизированные языки могут определить тип автоматически. Та же самая функция сложения в Haskell выглядит так: add x y = x + y . Мы не сообщаем языку типы, но он может определить их сам, потому что знает, что + работает только на числах, так что x и y должны быть числами, значит функция add принимает два числа как аргументы.


Это не уменьшает "статичность" системы типов. Система типов в Haskell знаменита своей статичностью, строгостью и мощностью, и в по всем этим фронтам Haskell опережает Java.

Динамически типизированные языки

Динамически типизированные языки не требуют указывать тип, но и не определяют его сами. Типы переменных неизвестны до того момента, когда у них есть конкретные значения при запуске. Например, функция в Python


def f(x, y): return x + y

может складывать два целых числа, склеивать строки, списки и так далее, и мы не можем понять, что именно происходит, пока не запустим программу. Возможно, в какой-то момент функцию f вызовут с двумя строками, и с двумя числами в другой момент. В таком случае x и y будут содержать значения разных типов в разное время. Поэтому говорят, что значения в динамических языках обладают типом, но переменные и функции - нет. Значение 1 это определенно integer, но x и y могут быть чем угодно.

Сравнение

Большинство динамических языков выдадут ошибку, если типы используются некорректно (JavaScript - известное исключение; он пытается вернуть значение для любого выражения, даже когда оно не имеет смысла). При использовании динамически типизированных языков даже простая ошибка вида "a" + 1 может возникнуть в боевом окружении. Статические языки предотвращают такие ошибки, но, конечно, степень предотвращения зависит от мощности системы типов.


Статические и динамические языки построены на фундаментально разных идеях о корректности программ. В динамическом языке "a" + 1 это корректная программа: код будет запущен и появится ошибка в среде исполнения. Однако, в большинстве статически типизированных языков выражение "a" + 1 - это не программа : она не будет скомпилирована и не будет запущена. Это некорректный код, так же, как набор случайных символов!&%^@*&%^@* - это некорректный код. Это дополнительное понятие о корректности и некорректности не имеет эквивалента в динамических языках.

Сильная и слабая типизация

Понятия "сильный" и "слабый" - очень неоднозначные. Вот некоторые примеры их использования:

    Иногда "сильный" означает "статический".
    Тут все просто, но лучше использовать термин "статический", потому что большинство используют и понимают его.

    Иногда "сильный" означает "не делает неявное преобразование типов".
    Например, JavaScript позволяет написать "a" + 1 , что можно назвать "слабой типизацией". Но почти все языки предоставляют тот или иной уровень неявного преобразования, которое позволяет автоматически переходить от целых чисел к числам с плавающей запятой вроде 1 + 1.1 . В реальности, большинство людей используют слово "сильный" для определения границы между приемлемым и неприемлемым преобразованием. Нет какой-то общепринятой границы, они все неточные и зависят от мнения конкретного человека.

    Иногда "сильный" означает, что невозможно обойти строгие правила типизации в языке.

  • Иногда "сильный" означает безопасный для памяти (memory-safe).
    Си - это пример небезопасного для памяти языка. Если xs - это массив четырех чисел, то Си с радостью выполнит код xs или xs , возвращая какое-то значение из памяти, которая находится сразу за xs .

Давайте остановимся. Вот как некоторые языки отвечают этим определениям. Как можно заметить, только Haskell последовательно "сильный" по всем параметрам. Большинство языков не такие четкие.



("Когда как" в колонке "Неявные преобразования" означает, что разделение между сильным и слабым зависит от того, какие преобразования мы считаем приемлемыми).


Зачастую термины "сильный" и "слабый" относятся к неопределенной комбинации разных определений выше, и других, не показанных здесь определений. Весь этот беспорядок делает слова "сильный" и "слабый" практически бессмысленными. Когда хочется использовать эти термины, то лучше описать, что конкретно имеется ввиду. Например, можно сказать, что "JavaScript возвращает значение, когда складывается строка с числом, но Python возвращает ошибку". В таком случае мы не будем тратить свои силы на попытки прийти к соглашению о множестве значений слова "сильный". Или, еще хуже: придем к неразрешенному непониманию из-за терминологии.


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



Сильная типизация: Система типов, которую я люблю и с которой мне комфортно.

Слабая типизация: Система типов, которая беспокоит меня или с которой мне не комфортно.

Постепенная типизация (gradual typing)

Можно ли добавить статические типы в динамические языки? В некоторых случаях - да. В других это сложно или невозможно. Самая очевидная проблема - это eval и другие похожие возможности динамических языков. Выполнение 1 + eval("2") в Python дает 3. Но что даст 1 + eval(read_from_the_network()) ? Это зависит от того, что в сети на момент выполнения. Если получим число, то выражение корректно. Если строку, то нет. Невозможно узнать до запуска, так что невозможно анализировать тип статически.


Неудовлетворительное решение на практике - это задать выражению eval() тип Any, что напоминает Object в некоторых объектно-ориентированных языках программирования или интерфейс interface {} в Go: это тип, которому удовлетворяет любое значение.


Значения типа Any не ограничены ничем, так что исчезает возможность системы типов помогать нам в коде с eval. Языки, в которых есть и eval и система типов, должны отказываться от безопасности типов при каждом использовании eval .


В некоторых языках есть опциональная или постепенная типизация (gradual typing): они динамические по умолчанию, но позволяют добавлять некоторые статические аннотации. В Python недавно добавили опциональные типы; TypeScript - это надстройка над JavaScript, в котором есть опциональные типы; Flow производит статический анализ старого доброго кода на JavaScript.


Эти языки предоставляют некоторые преимущества статической типизации, но они никогда не дадут абсолютной гарантии, как по-настоящему статические языки. Некоторые функции будут статически типизированными, а некоторые будут динамически типизированными. Программисту всегда нужно знать и опасаться разницы.

Компиляция статически типизированного кода

Когда происходит компиляция статически типизированного кода, сначала проверяется синтаксис, как в любом компиляторе. Потом проверяются типы. Это означает, что статический язык сначала может пожаловаться на одну синтаксическую ошибку, а после ее исправления пожаловаться на 100 ошибок типизации. Исправление синтаксической ошибки не создало эти 100 ошибок типизации. Компилятор просто не имел возможности обнаружить ошибки типов, пока не был исправлен синтаксис.


Компиляторы статических языков обычно могут генерировать более быстрый код, чем компиляторы динамических. Например, если компилятор знает, что функция add принимает целые числа, то он может использовать нативную инструкцию ADD центрального процессора. Динамический язык будет проверять тип при выполнении, выбирая один из множества функций add в зависимости от типов (складываем integers или floats или склеиваем строки или, может быть, списки?) Или нужно решить, что возникла ошибка и типы не соответствуют друг другу. Все эти проверки занимают время. В динамических языках используются разные трюки для оптимизации, например JIT-компиляция (just-in-time), где код перекомпилируется при выполнении после получения всей необходимой о типах информации. Однако, никакой динамический язык не может сравниться по скоростью с аккуратно написанным статическим кодом на языке вроде Rust.

Аргументы в пользу статических и динамических типов

Сторонники статической системы типов указывают на то, что без системы типов простые ошибки могут привести к проблемам в продакшене. Это, конечно же, правда. Любой, кто использовал динамический язык, испытал это на себе.


Сторонники динамических языков указывают на то, что на таких языках, кажется, легче писать код. Это определенно справедливо для некоторых видов кода, который мы время от времени пишем, как, например, тот код с eval . Это спорное решение для регулярной работы, и здесь имеет смысл вспомнить неопределенное слово "легко". Рич Хики отлично рассказал про слово "легко", и его связь со словом "просто". Посмотрев этот доклад вы поймете, что не легко правильно использовать слово "легко". Опасайтесь "легкости".


Плюсы и минусы статических и динамических систем типизации все еще плохо изучены, но они определенно зависят от языка и конкретной решаемой задачи.


JavaScript пытается продолжить работу, даже если это означает бессмысленную конвертацию (вроде "a" + 1 , дающее "a1"). Python в свою очередь старается быть консервативным и часто возвращает ошибки, как в случае с "a" + 1 .


Существуют разные подходы с разными уровнями безопасности, но Python и JavaScript оба являются динамически типизированными языками.



Haskell же не позволит сложить integer и float без явного преобразования перед этим. Си и Haskell оба являются статически типизированными, не смотря на такие большие отличия.


Есть множество вариаций динамических и статических языков. Любое безоговорочное высказывание вида "статические языки лучше, чем динамические, когда дело касается Х" - это почти гарантированно ерунда. Это может быть правдой в случае конкретных языков, но тогда лучше сказать "Haskell лучше, чем Python когда дело касается Х".

Разнообразие статических систем типизации

Давайте взглянем на два знаменитых примера статически типизированных языков: Go и Haskell. В системе типизации Go нет обобщенных типов, типов с "параметрами" от других типов. Например, можно создать свой тип для списков MyList, который может хранить любые нужные нам данные. Мы хотим иметь возможность создавать MyList целых чисел, MyList строк и так далее, не меняя исходный код MyList. Компилятор должен следить за типизацией: если есть MyList целых чисел, и мы случайно добавляем туда строку, то компилятор должен отклонить программу.


Go специально был спроектирован таким образом, чтобы невозможно было задавать типы вроде MyList. Лучшее, что возможно сделать, это создать MyList "пустых интерфейсов": MyList может содержать объекты, но компилятор просто не знает их тип. Когда мы достаем объекты из MyList, нам нужно сообщить компилятору их тип. Если мы говорим "Я достаю строку", но в реальности значение - это число, то будет ошибка исполнения, как в случае с динамическими языками.


В Go также нет множества других возможностей, которые присутствуют в современных статически типизированных языках (или даже в некоторых системах 1970-х годов). У создателей Go были свои причины для этих решений, но мнение людей со стороны по этому поводу иногда может звучать резко.


Теперь давайте сравним с Haskell, который обладает очень мощной системой типов. Если задать тип MyList, то тип "списка чисел" это просто MyList Integer . Haskell не даст нам случайно добавить строку в список, и удостоверится, что мы не положим элемент из списка в строковую переменную.


Haskell может выражать намного более сложные идеи напрямую типами. Например, Num a => MyList a означает "MyList значений, которые относятся к одному типу чисел". Это может быть список integer"ов, float"ов или десятичных чисел с фиксированной точностью, но это определенно никогда не будет списком строк, что проверяется при компиляции.


Можно написать функцию add, которая работает с любыми численными типами. У этой функции будет тип Num a => (a -> a -> a) . Это означает:

  • a может быть любым численным типом (Num a =>).
  • Функция принимает два аргумента типа a и возвращает тип a (a -> a -> a).

Последний пример. Если тип функции это String -> String , то она принимает строку и возвращает строку. Но если это String -> IO String , то она также совершает какой-то ввод/вывод. Это может быть обращение к диску, к сети, чтение из терминала и так далее.


Если у функции в типе нет IO, то мы знаем, что она не совершает никаких операций ввода/вывода. В веб-приложении, к примеру, можно понять, изменяет ли функция базу данных, просто взглянув на ее тип. Никакие динамические и почти никакие статические языки не способы на такое. Это особенность языков с самой мощной системой типизации.


В большинстве языков нам пришлось бы разбираться с функцией и всеми функциями, которые оттуда вызываются, и так далее, в попытках найти что-то, изменяющее базу данных. Это утомительный процесс, в котором легко допустить ошибку. А система типов Haskell может ответить на этот вопрос просто и гарантированно.


Сравните эту мощность с Go, который не способен выразить простую идею MyList, не говоря уже о "функции, которая принимает два аргумента, и они оба численные и одного типа, и которая делает ввод/вывод".


Подход Go упрощает написание инструментов для программирования на Go (в частности, реализация компилятора может быть простой). К тому же, требуется изучить меньше концепций. Как эти преимущества сравнимы со значительными ограничениями - субъективный вопрос. Однако, нельзя поспорить, что Haskell сложнее изучить, чем Go, и что система типов в Haskell намного мощнее, и что Haskell может предотвратить намного больше типов багов при компиляции.


Go и Haskell настолько разные языки, что их группировка в один класс "статических языков" может вводить в заблуждение, не смотря на то, что термин используется корректно. Если сравнивать практические преимущества безопасности, то Go ближе к динамических языкам, нежели к Haskell"у.


С другой стороны, некоторые динамические языки безопаснее, чем некоторые статические языки. (Python в целом считается намного безопаснее, чем Си). Когда хочется делать обобщения о статических или динамических языках как группах, то не забывайте об огромном количестве отличий между языками.

Конкретные примеры отличия в возможностях систем типизации

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


В Go можно сказать "функция add принимает два integer"а и возвращает integer":


func add(x int, y int) int { return x + y }

В Haskell можно сказать "функция принимает любой численный тип и возвращает число того же типа":


f:: Num a => a -> a -> a add x y = x + y

В Idris можно сказать "функция принимает два integer"а и возвращает integer, но первый аргумент должен быть меньше второго аргумента":


add: (x: Nat) -> (y: Nat) -> {auto smaller: LT x y} -> Nat add x y = x + y

Если попытаться вызвать функцию add 2 1 , где первый аргумент больше второго, то компилятор отклонит программу во время компиляции . Невозможно написать программу, где первый аргумент больше второго. Редкий язык обладает такой возможностью. В большинстве языков такая проверка происходит при выполнении: мы бы написали что-то вроде if x >= y: raise SomeError() .


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

Системы типизации некоторых статических языков

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

  • C (1972), Go (2009) : Эти системы совсем не мощные, без поддержки обобщенных типов. Невозможно задать тип MyList, который бы означал "список целых чисел", "список строк" и т.д. Вместо этого придется делать "список необозначенных значений". Программист должен вручную сообщать "это список строк" каждый раз, когда строка извлекается из списка, и это может привести к ошибке при исполнении.
  • Java (1995), C# (2000) : Оба языка поддерживают обобщенные типы, так что можно сказать MyList и получить список строк, о котором компилятор знает и может следить за соблюдением правил типов. Элементы из списка будут обладать типом String, компилятор будет форсировать правила при компиляции как обычно, так что ошибки при исполнении менее вероятны.
  • Haskell (1990), Rust (2010), Swift (2014) : Все эти языки обладают несколькими продвинутыми возможностями, в том числе обобщенными типами, алгебраическими типами данных (ADTs), и классами типов или чем-то похожим (типы классов, признаки (traits) и протоколы, соответственно). Rust и Swift более популярны, чем Haskell, и их продвигают крупные организации (Mozilla и Apple, соответственно).
  • Agda (2007), Idris (2011) : Эти языки поддерживают зависимые типы, позволяя создавать типы вроде "функция, которая принимает два целых числа х и y, где y больше, чем x". Даже ограничение "y больше, чем x" форсируется при компиляции. При выполнении y никогда не будет меньше или равно x, что бы ни случилось. Очень тонкие, но важные свойства системы могут быть проверены статически в этих языках. Их изучает очень мало программистов, но эти языки вызывают у них огромный энтузиазм.

Наблюдается явное движение в сторону более мощных систем типизации, особенно если судить по популярности языков, а не по простому факту существования языков. Известное исключение - это Go, который объясняет, почему многие сторонники статических языков считают его шагом назад.


Группа два (Java и C#) - это мэйнстримовые языки, зрелые и широко используемые.


Группа три находится на пороге входа в мэйнстрим, с большой поддержкой со стороны Mozilla (Rust) и Apple (Swift).


Группа четыре (Idris and Agda) далеки от мэйнстрима, но это может измениться со временем. Языки группы три были далеко от мэйнстрима еще десять лет назад.

Строгая типизация - один из вариантов политики работы с типами данных, которая используется в языках программирования.

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

  1. Любой объект данных (переменная, константа, выражение) в языке всегда имеет строго определённый тип , который фиксируется на момент компиляции программы (статическая типизация) или определяется во время выполнения (динамическая типизация).
  2. Допускается присваивание переменной только значения, имеющего строго тот же тип данных, что и переменная, те же ограничения действуют в отношении передачи параметров и возврата результатов функций.
  3. Каждая операция требует параметров строго определённых типов.
  4. Неявное преобразование типов не допускается (то есть транслятор воспринимает любую попытку использовать значение не того типа, который был описан для переменной, параметра, функции или операции, как синтаксическую ошибку).

При точном следовании требованиям строгой типизации даже одинаковые по составу значений и допустимым операциям типы данных являются несовместимыми. Если в программе необходимо присвоить значение одного типа данных переменной другого типа, это можно сделать, но только путём явного применения специальной операции преобразования типа, которая в таких случаях обычно является частью языка программирования (хотя может формально и не являться ею, а предоставляться стандартными библиотеками).

Единственный практически используемый язык программирования со строгой типизацией - это Ада . Довольно большое число распространённых языков программирования используют нестрогую статическую типизацию. К таким языкам относятся, например Pascal , Модула-2 , Java . В них обязательно описание типов переменных, параметров и функций, но допускается неявное приведение типов - в случае, если значение одного типа присваивается переменной другого, то компилятор автоматически генерирует код для преобразования значения в нужный тип, если только такое преобразование не приводит к потере данных. Так, например, целое число можно присваивать переменной, объявленной как число с плавающей точкой, а обратное присваиваение без явного приведения типов запрещено, поскольку с высокой вероятностью приведёт к ошибке. Некоторые языки, формально имеющие понятие типа данных, в действительности можно считать нетипизированными. К таким языкам относится классический Си , в котором, хотя объявление типов и требуется, в действительности все типы данных являются совместимыми по присваиванию (современные компиляторы Си ограничивают эту свободу и выдают, по меньшей мере, предупреждения при опасных преобразованиях типов).

В теории программирования строгая типизация является непременным элементом обеспечения надёжности разрабатываемых программных средств. При правильном применении (подразумевающем, что в программе объявляются и используются отдельные типы данных для логически несовместимых значений) она защищает программиста от простых, но труднообнаруживаемых ошибок, связанных с совместным использованием логически несовместимых значений, возникающих иногда просто из-за элементарной описки. Подобные ошибки выявляются ещё на этапе компиляции программы, тогда как при возможности неявного приведения практически любых типов друг к другу (как, например, в классическом языке Си) эти ошибки выявляются только при тестировании, причём не все и не сразу. С другой стороны, многие профессиональные программисты не любят строгую типизацию из-за её неудобства - она увеличивает объём программы и время её написания, требует более тщательной проработки кода, что многим кажется излишним.