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


Оглавление (нажмите, чтобы открыть):

Ликбез по типизации в языках программирования

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

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

Рекомендую прочитать сначала краткую версию статьи, а затем при наличии желания и полную.

Краткая версия

Языки программирования по типизации принято делить на два больших лагеря — типизированные и нетипизированные ( бестиповые ). К первому например относятся C, Python, Scala, PHP и Lua, а ко второму — язык ассемблера, Forth и Brainfuck.

Так как «бестиповая типизация» по своей сути — проста как пробка, дальше она ни на какие другие виды не делится. А вот типизированные языки разделяются еще на несколько пересекающихся категорий:


    Статическая / динамическая типизация. Статическая определяется тем, что конечные типы переменных и функций устанавливаются на этапе компиляции. Т.е. уже компилятор на 100% уверен, какой тип где находится. В динамической типизации все типы выясняются уже во время выполнения программы.

Примеры:
Статическая: C, Java, C#;
Динамическая: Python, JavaScript, Ruby.
Сильная / слабая типизация (также иногда говорят строгая / нестрогая). Сильная типизация выделяется тем, что язык не позволяет смешивать в выражениях различные типы и не выполняет автоматические неявные преобразования, например нельзя вычесть из строки множество. Языки со слабой типизацией выполняют множество неявных преобразований автоматически, даже если может произойти потеря точности или преобразование неоднозначно.

Примеры:
Сильная: Java, Python, Haskell, Lisp;
Слабая: C, JavaScript, Visual Basic, PHP.
Явная / неявная типизация. Явно-типизированные языки отличаются тем, что тип новых переменных / функций / их аргументов нужно задавать явно. Соответственно языки с неявной типизацией перекладывают эту задачу на компилятор / интерпретатор.

Примеры:
Явная: C++, D, C#
Неявная: PHP, Lua, JavaScript

Также нужно заметить, что все эти категории пересекаются, например язык C имеет статическую слабую явную типизацию, а язык Python — динамическую сильную неявную.

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

Подробная версия

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

Бестиповая типизация

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

Бестиповая типизация обычно присуща низкоуровневым (язык ассемблера, Forth) и эзотерическим (Brainfuck, HQ9, Piet) языкам. Однако и у нее, наряду с недостатками, есть некоторые преимущества.

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

  • Позволяет писать на предельно низком уровне, причем компилятор / интерпретатор не будет мешать какими-либо проверками типов. Вы вольны производить любые операции над любыми видами данных.
  • Получаемый код обычно более эффективен.
  • Прозрачность инструкций. При знании языка обычно нет сомнений, что из себя представляет тот или иной код.

Недостатки

  • Сложность. Часто возникает необходимость в представлении комплексных значений, таких как списки, строки или структуры. С этим могут возникнуть неудобства.
  • Отсутствие проверок. Любые бессмысленные действия, например вычитание указателя на массив из символа будут считаться совершенно нормальными, что чревато трудноуловимыми ошибками.
  • Низкий уровень абстракции. Работа с любым сложным типом данных ничем не отличается от работы с числами, что конечно будет создавать много трудностей.

Сильная безтиповая типизация?

Да, такое существует. Например в языке ассемблера (для архитектуры х86/х86-64, других не знаю) нельзя ассемблировать программу, если вы попытаетесь загрузить в регистр cx (16 бит) данные из регистра rax (64 бита).

mov cx, eax ; ошибка времени ассемблирования

Так получается, что в ассемлере все-таки есть типизация? Я считаю, что этих проверок недостаточно. А Ваше мнение, конечно, зависит только от Вас.

Статическая и динамическая типизации

Главное, что отличает статическую (static) типизацию от динамической (dynamic) то, что все проверки типов выполняются на этапе компиляции, а не этапе выполнения.

Некоторым людям может показаться, что статическая типизация слишком ограничена (на самом деле так и есть, но от этого давно избавились с помощью некоторых методик). Некоторым же, что динамически типизированные языки — это игра с огнем, но какие же черты их выделяют? Неужели оба вида имеют шансы на существование? Если нет, то почему много как статически, так и динамически типизированных языков?

Преимущества статической типизации

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

Преимущества динамической типизации

  • Простота создания универсальных коллекций — куч всего и вся (редко возникает такая необходимость, но когда возникает динамическая типизация выручит).
  • Удобство описания обобщенных алгоритмов (например сортировка массива, которая будет работать не только на списке целых чисел, но и на списке вещественных и даже на списке строк).
  • Легкость в освоении — языки с динамической типизацией обычно очень хороши для того, чтобы начать программировать.

Обобщенное программирование

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

Как же мы будем ее решать? Решим ее на 3-ех разных языках: одном с динамической типизацией и двух со статической.

Алгоритм поиска я возьму один из простейших — перебор. Функция будет получать искомый элемент, сам массив (или список) и возвращать индекс элемента, или, если элемент не найден — (-1).

Динамическое решение (Python):

Как видите, все просто и никаких проблем с тем, что список может содержать хоть числа, хоть списки, хоть другие массивы нет. Очень хорошо. Давайте пойдем дальше — решим эту-же задачу на Си!

Статическое решение (Си):

Ну, каждая функция в отдельности похожа на версию из Python, но почему их три? Неужели статическое программирование проиграло?

И да, и нет. Есть несколько методик программирования, одну из которых мы сейчас рассмотрим. Она называется обобщенное программирование и язык C++ ее неплохо поддерживает. Давайте посмотрим на новую версию:

Статическое решение (обобщенное программирование, C++):

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

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

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

Статика в динамике

Также нужно упомянуть, что многие статические языки позволяют использовать динамическую типизацию, например:

  • C# поддерживает псевдо-тип dynamic.
  • F# поддерживает синтаксический сахар в виде оператора ?, на базе чего может быть реализована имитация динамической типизации.
  • Haskell — динамическая типизация обеспечивается модулем Data.Dynamic.
  • Delphi — посредством специального типа Variant.

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

  • Common Lisp — декларации типов.
  • Perl — с версии 5.6, довольно ограниченно.

Итак, идем дальше?

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

Языки с сильной типизацией не позволяют смешивать сущности разных типов в выражениях и не выполняют никаких автоматических преобразований. Также их называют «языки с строгой типизацией». Английский термин для этого — strong typing.

Слабо типизированные языки, наоборот всячески способствуют, чтобы программист смешивал разные типы в одном выражении, причем компилятор сам приведет все к единому типу. Также их называют «языки с нестрогой типизацией». Английский термин для этого — weak typing.

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

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

Язык при этом должен иметь еще и сильную типизацию. И правда, если компилятор вместо сообщения об ошибке будет просто прибавлять строку к числу, или что еще хуже, вычтет из одного массива другой, какой нам толк, что все «проверки» типов будут на этапе компиляции? Правильно — слабая статическая типизация еще хуже, чем сильная динамическая! (Ну, это мое мнение)

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

Хотите узнать какие?

Преимущества сильной типизации

  • Надежность — Вы получите исключение или ошибку компиляции, взамен неправильного поведения.
  • Скорость — вместо скрытых преобразований, которые могут быть довольно затратными, с сильной типизацией необходимо писать их явно, что заставляет программиста как минимум знать, что этот участок кода может быть медленным.
  • Понимание работы программы — опять-же, вместо неявного приведения типов, программист пишет все сам, а значит примерно понимает, что сравнение строки и числа происходит не само-собой и не по-волшебству.
  • Определенность — когда вы пишете преобразования вручную вы точно знаете, что вы преобразуете и во что. Также вы всегда будете понимать, что такие преобразования могут привести к потере точности и к неверным результатам.

Преимущества слабой типизации

  • Удобство использования смешанных выражений (например из целых и вещественных чисел).
  • Абстрагирование от типизации и сосредоточение на задаче.
  • Краткость записи.

Ладно, мы разобрались, оказывается у слабой типизации тоже есть преимущества! А есть ли способы перенести плюсы слабой типизации в сильную?

Оказывается есть и даже два.

Неявное приведение типов, в однозначных ситуациях и без потерь данных

Ух… Довольно длинный пункт. Давайте я буду дальше сокращать его до «ограниченное неявное преобразование» Так что же значит однозначная ситуация и потери данных?

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

Потеря данных это еще проще. Если мы преобразуем вещественное число 3.5 в целое — мы потеряем часть данных (на самом деле эта операция еще и неоднозначная — как будет производиться округление? В большую сторону? В меньшую? Отбрасывание дробной части?).

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

Если вы мне не верите, изучите язык PL/I или даже просто поищите его спецификацию. В нем есть правила преобразования между ВСЕМИ типами данных! Это просто ад!

Ладно, давайте вспомним про ограниченное неявное преобразование. Есть ли такие языки? Да, например в Pascal Вы можете преобразовать целое число в вещественное, но не наоборот. Также похожие механизмы есть в C#, Groovy и Common Lisp.

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

Я поясню его на примере замечательного языка Haskell.

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

Например в выражении pi + 1 , не хочется писать pi + 1.0 или pi + float(1) . Хочется написать просто pi + 1 !

И это сделано в Haskell, благодаря тому, что у литерала 1 нет конкретного типа. Это ни целое, ни вещественное, ни комплексное. Это же просто число!

В итоге при написании простой функции sum x y , перемножающей все числа от x до y (с инкрементом в 1), мы получаем сразу несколько версий — sum для целых, sum для вещественных, sum для рациональных, sum для комплексных чисел и даже sum для всех тех числовых типов что Вы сами определили.

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

Таким образом можно сказать, что лучшим выходом будет балансирование на грани, между сильной и слабой типизацией. Но пока идеальный баланс не держит ни один язык, поэтому я больше склоняюсь к сильно типизированным языкам (таким как Haskell, Java, C#, Python), а не к слабо типизированным (таким как C, JavaScript, Lua, PHP).

Ладно, пойдем дальше?

Явная и неявная типизации

Язык с явной типизацией предполагает, что программист должен указывать типы всех переменных и функций, которые объявляет. Английский термин для этого — explicit typing.

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

По-началу можно решить, что неявная типизация равносильна динамической, а явная — статической, но дальше мы увидим, что это не так.

Есть ли плюсы у каждого вида, и опять же, есть ли их комбинации и есть ли языки с поддержкой обоих методов?

Преимущества явной типизации

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

Преимущества неявной типизации

  • Сокращение записи — def add(x, y) явно короче, чем int add( int x, int y) .
  • Устойчивость к изменениям. Например если в функции временная переменная была того-же типа, что и входной аргумент, то в явно типизированном языке при изменении типа входного аргумента нужно будет изменить еще и тип временной переменной.

Хорошо, видно, что оба подхода имеют как плюсы так и минусы (а кто ожидал чего-го еще?), так давайте поищем способы комбинирования этих двух подходов!

Явная типизация по-выбору

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


Примечание: я намерено использовал некаррированную функцию, а также намерено записал частную сигнатуру вместо более общей add :: (Num a) -> a -> a -> a , т.к. хотел показать идею, без объяснения синтаксиса Haskell’а.

Хм. Как мы видим, это очень красиво и коротко. Запись функции занимает всего 18 символов на одной строчке, включая пробелы!

Однако автоматический вывод типов довольно сложная вещь, и даже в таком крутом языке как Haskell, он иногда не справляется. (как пример можно привести ограничение мономорфизма)

Есть ли языки с явной типизацией по-умолчанию и неявной по-необходимости? Кон
ечно.

Неявная типизация по-выбору

В новом стандарте языка C++, названном C++11 (ранее назывался C++0x), было введено ключевое слово auto, благодаря которому можно заставить компилятор вывести тип, исходя из контекста:

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

Ух ты! Вот это сокращение. Ладно, но можно ли сделать что-нибудь в духе Haskell, где тип возвращаемого значения будет зависеть от типов аргументов?

И опять ответ да, благодаря ключевому слову decltype в комбинации с auto:

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

Некоторые языки программирования по данной классификации

Я приведу небольшой список из популярных языков и напишу как они подразделяются по каждой категории «типизаций».

Возможно я где-то ошибся, особенно с CL, PHP и Obj-C, если по какому-то языку у Вас другое мнение — напишите в комментариях.

Заключение

Окей. Уже скоро будет светло и я чувствую, что про типизацию больше нечего сказать. Ой как? Тема бездонная? Очень много осталось недосказано? Прошу в комментарии, поделитесь полезной информацией.

Что такое динамическая типизация?

Чтобы максимально просто объяснить две абсолютно разные технологии, начнём сначала. Первое, с чем сталкивается программист при написании кода – объявление переменных. Вы можете заметить, что, например, в языке программирования C++ необходимо указывать тип переменной. То есть если вы объявляете переменную x, то обязательно нужно добавить int – для хранения целочисленных данных, float – для хранения данных с плавающей точкой, char – для символьных данных, и другие доступные типы. Следовательно, в C++ используется статическая типизация, так же как и в его предшественнике C.

Как работает статическая типизация?

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

Рассмотрим небольшой пример. При инициализации переменной x (int x;) мы указываем идентификатор int — это сокращение от Integer типа, который хранит только целые числа в диапазоне от – 2 147 483 648 до 2 147 483 647. Таким образом, компилятор понимает, что может выполнять над этой переменной математические значения – сумму, разность, умножение и деление. А вот, например, функцию strcat(), которая соединяет два значения типа char, применить к x нельзя. Ведь если снять ограничения и попробовать соединить два значения int символьным методом, тогда произойдет ошибка.

Зачем понадобились языки с динамической типизацией?

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

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

Какой вид типизации лучше?

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

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

Разделение на «сильную» и «слабую» типизацию

Среди как русскоязычных, так и англоязычных материалов по программированию можно встретить выражение – «сильная» типизация. Это не отдельное понятие, а точнее такого понятия в профессиональном лексиконе вообще не существует. Хотя многие пытаются его по-разному интерпретировать. На самом деле, «сильную» типизацию следует понимать как ту, которая удобна именно для вас и с которой максимально комфортно работать. А «слабая» — неудобная и неэффективная для вас система.

Особенность динамики

Наверняка вы замечали, что на стадии написании кода компилятор анализирует написанные конструкции и выдаст ошибку при несовпадении типов данных. Но только не JavaScript. Его уникальность в том, что он в любом случае произведет операцию. Вот легкий пример – мы хотим сложить символ и число, что не имеет смысла: «x» + 1.

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

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

Возможны ли смежные архитектуры?

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

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

  • В языке программирования Delphi – подсистема Variant.
  • В языке программирования AliceML – дополнительные пакеты.
  • В языке программирования Haskell – библиотека Data.Dynamic.

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

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

Преимущества динамической типизации

  • Сводит к минимуму количество символов и строк кода из-за ненадобности предварительного объявления переменных и указания их типа. Тип будет определен автоматически, после присвоения значения.
  • В небольших блоках кода упрощается визуальное и логическое восприятие конструкций, из-за отсутствия «лишних» строк объявления.
  • Динамика положительно влияет на скорость работы компилятора, так как он не учитывает типы, и не проверяет их на соответствие.
  • Повышает гибкость и позволяет создавать универсальные конструкции. К примеру, при создании метода, который должен взаимодействовать с массивом данных, не нужно создавать отдельные функции для работы с числовыми, текстовыми и другими типами массивов. Достаточно написать один метод, и он будет работать с любыми типами.
  • Упрощает вывод данных из систем управления базами данных, поэтому динамическую типизацию активно используют при разработке веб-приложений.
  • Если была допущена опечатка или грубая ошибка при использовании или объявлении переменных, то компилятор не отобразит её. А проблемы возникнут при выполнении программы.
  • При использовании статической типизации все объявления переменных и функций обычно выносятся в отдельный файл, который позволяет в дальнейшем с легкостью создать документацию или вообще использовать сам файл как документацию. Соответственно, динамическая типизация не позволяет использовать такую особенность.

Подробнее о языках программирования со статической типизацией

  • C++ — наиболее распространенный язык программирования общего назначения. На сегодняшний день имеет несколько крупных редакций и большую армию пользователей. Стал популярным благодаря своей гибкости, возможности безграничного расширения и поддержке различных парадигм программирования.
  • Java – язык программирования, который использует объектно-ориентированный подход. Получил распространение благодаря мультиплатформенности. При компиляции код интерпретируется в байт-код, который может выполняться на любой операционной системе. Java и динамическая типизация несовместимы, так как язык строго типизирован.
  • Haskell – также один из популярных языков, код которого может интегрироваться в другие языки и взаимодействовать вместе с ними. Но, несмотря на такую гибкость, имеет строгую типизацию. Оснащен большим встроенным набором типов и возможностью создания собственных.

Подробнее о языках программирования с динамическим видом типизации

  • Python – язык программирования, который создавался прежде всего для облегчения работы программиста. Имеет ряд функциональных улучшений, благодаря которым увеличивает читабельность кода и его написание. Во многом этого удалось добиться благодаря динамической типизации.
  • PHP – язык для создания скриптов. Повсеместно применяется в веб-разработке, обеспечивая взаимодействие с базами данных, для создания интерактивных динамических веб-страниц. Благодаря динамической типизации существенно облегчается работы с базами данных.
  • JavaScript – уже упоминавшийся выше язык программирования, который нашел применение в веб-технологиях для создания веб-сценариев, выполняющихся на стороне клиента. Динамическая типизация используется для облегчения написания кода, ведь обычно он разбивается на небольшие блоки.

Динамический вид типизации — недостатки

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

Подведем итог

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

Типизация языков программирования

Типизация — назначения типа информационным сущностям.

Наиболее распространённые примитивные типы данных:

  • Числовой
  • Символьный
  • Логический

Основные функции системы типов данных:

  • Обеспечение безопасности
    Проверяется каждая операция на получение аргументов именно тех типов, для которых она имеет предназначена;
  • Оптимизация
    На основе типа выбирается способ эффективного хранения и алгоритмов его обработки;
  • Документация
    Подчеркивается намерения программиста;
  • Абстракция
    Использование типов данных высокого уровня позволяет программисту думать о значениях как о высокоуровневых сущностях, а не как о наборе битов.

Классификация

Есть множество классификаций типизаций языков программирования, но основные только 3:

Статическая / динамическая типизация

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

Динамическая типизация является противоположностью статической типизации. В динамической типизации все типы выясняются во время выполнения программы.

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

Примеры:

  • Статическая: Java, C#, TypeScript.
  • Динамическая: Python, Ruby, JavaScript.

Явная / неявная типизация.

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

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

Явная типизация (вымышленный язык похожий на JS)

Строгая / нестрогая типизация

Также называется сильная / слабая типизация. При строгой типизации типы назначаются «раз и навсегда», при нестрогой могут изменяться в процессе выполнения программы.

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

Строгая типизация (вымышленный язык похожий на JS)

Нестрогая типизация (как есть в js)

Примеры:

  • Строгая: Java, Python, Haskell, Lisp.
  • Нестрогая: C, JavaScript, Visual Basic, PHP.


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

В JavaScript кроме undefined существует null . Оно означает, что «значение отсутствует». Например, если создать переменную, но не задавать ей значения, то у нее будет значение undefined:

Тут значения не оказалось ненамеренно. Видимо, просто еще не пришло время дать этой переменной значение.

null нужен для явного, намеренного указания, что значения нет. Можно сказать let a = null; . Например, вы попросили пользователя ввести информацию, но он ничего не ввел. В таком случае уместно записать в результат null .

null , в отличие от undefined , можно задавать вручную, передавать как аргумент в функцию и в целом использовать как любое другое явное значение.

( undefined тоже можно задавать вручную, но никогда не нужно этого делать: это значение семантически создано только для того, чтобы его генерировал компьютер, а не программист).

При сравнении null и undefined нужно быть осторожным:

Сравнение

В этом курсе мы сравниваем данные, используя три знака равенства:

Это сравнение прямое: являются ли эти данные абсолютно идентичными?

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

Конспект урока

Типизация в JavaScript

JavaScript имеет представление о типах: числах, строках, функциях, логических значениях и так далее. typeof возвращает строку, в которой записан тип:

NaN означает «не число», но тип этого значения — number .

Переменная без значения имеет специальное значение undefined . Тип такой переменной — undefined :

Динамическая и статическая типизация

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

После того, как компиляция закончена, запускается программа и период, пока она запущена, называется стадией исполнения (run time).

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

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

Иными словами: статическое типизирование означает проверку типов перед запуском программы; динамическое — проверку типов пока программа запущена.

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

JavaScript часто конвертирует типы автоматически:

JavaScript — это язык со слабой типизацией. У него есть представление о типах, но он расслаблено к ним относится и может оперировать значениями, можно сказать, произвольно. Чем сильнее система типизации, тем строже правила.

Явные конверсии в JavaScript

Опционально

Транскрипт урока

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

Взгляните на этот код:

Сначала мы создали константу. Помните, что это как давать чему-то название: в нашем случае — числу 12 даётся название length . В следующей строке мы вызываем функцию length и передаём ей аргумент — число 54. Но подождите! length — это не функция! Это всего лишь число. Числа — это не функции, не ящики, которые производят какие-то действия. И JavaScript пожалуется именно на это:

Это Ошибка типизации: тип объекта, который вы использовали, неверный. Интерпретатор JavaScript не скажет чем что-то является, но точно скажет чем оно не является. length — это не функция.

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

В программировании «типизация» — это классификация информации. Это общий термин и разные языки программирования справляются с типизацией по-разному. Как вы уже знаете, JavaScript умеет отличать типы. Функция — это один тип, Число — другой, и вы не можете просто использовать число как функцию.

typeof — это специальный оператор, который возвращает строку, в которой написан тип.

42 и 3.14, очевидно, числа, несколько комбинаций букв в кавычках — строка, а true и false — булеан. Всё это — типы в JavaScript — число, строка и булеан.

NaN означает — «не число», но тип NaN — это «число». Да, я знаю. Еще одна странность JavaScript. Такие правила в этом языке.

Типизация полезна. Когда мы попытаемся запустить число, как будто это функция, JavaScript начнёт жаловаться и мы увидим ошибку и починим её. Если бы никакого обозначения типов в JavaScript не было, мы бы сталкивались либо с каким-нибудь аномальным поведением, либо с мистической ошибкой. Вместо чёткого «length — это не функция», мы бы видели что-то вроде «I’m sorry Dave, I’m afraid I can’t do that».

А что, если создать переменную, но не задать ей никакого значения? Какой в этом случае будет тип? Это ни число, ни строка, ничто. Потому что нет значения, правильно?

JavaScript в этом случае кое-что делает в тайне от вас. Переменная без значения на самом деле имеет специальное значение — «undefined». И тип такой переменной называется «undefined».

Например, тип number имеет множество потенциальных значений: 1, 2, -10, 69000 и другие числа. А тип undefined только одно — undefined .

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

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

Код, который вы пишите, обычно конвертируется в понятную для запуска компьютером форму. Этот процесс называется компиляцией, а промежуток времени, за который это происходит — «стадией компиляции» или compile time.

После того, как компиляция закончена и программа запущена, начинается отсчёт времени, который называется «стадией исполнения» или run time.

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

Другие языки проверяют типы и ищут ошибки типизации на стадии исполнения. Такая типизация — динамическая.

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

C#, C++, Java, Go — статически типизированные языки. Если в одном из этих языков вы создадите число и попытаетесь проводить с ним операции, как с функцией, вы получите ошибку во время компиляции, а программа не станет запускаться — она даже не дойдёт до этой стадии, потому что ошибка типизации будет обнаружена перед исполнением, в период компиляции.

JavaScript, Ruby, PHP — динамически типизированные языки. Как вы видели раньше, если использовать неверную типизацию, ваша программа запустится, а ошибка обнаружится только когда будет исполняться конкретная строчка кода. Здесь типы проверяются в период исполнения.

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

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

Теперь давайте поговорим о слабой и сильной типизации. Посмотрите на этот JavaScript код:

М-да… Это… Ок, что тут происходит? Сложение числа 4 со строкой «7» даёт нам строку «47». JavaScript конвертирует число 4 в строку «4» и конкатенирует две строки — склеивает их друг с другом. JavaScript просто берёт на себя ответственность предположить, что это то, что мы хотели. Глупо обвинять его — чего мы действительно хотели? Складывать число со строкой не имеет никакого смысла. Какой-нибудь другой язык, вроде Ruby или Python просто бы пожаловался и ничего не сделал.

Произведение числа 4 со строкой «7», это, как видите, 28, по мнению JavaScript. В этом случае он сконвертировал строку «7» в число 7 и произвёл обычное умножение.

JavaScript постоянно так делает. Он знает о типах разных значений, но когда типы не соответствуют, он пытается предположить и сконвертировать один тип в другой, не предупреждая вас. Иногда это полезно, иногда мозгодробяще. Такое происходит потому что JavaScript — язык со слабой типизацией. У него есть представление о типах, но он типа «это всего лишь игра, чего ты злишься?»

У этой концепции нет ничего общего с динамической и статической типизацией, смысл которых — КОГДА проверять типы. Сильная против слабой — это НАСКОЛЬКО СЕРЬЁЗНО проверять типы.

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

В отличие от динамичности-статичности, сила типизации это спектр. У PHP типизация немного сильнее. У Python ещё сильнее. И все они динамически типизированные языки.

JavaScript делает множество неявных конверсий, но он так же даёт нам инструменты, чтобы мы могли делать явные конверсии сами. Мы можем конвертировать строки в числа, числа в строки, булевы в строки и так далее:

Можно предположить, что неявная конверсия из типа в тип — не самая лучшая идея. Неявный, значит скрытый, а скрытый — значит трудно понимаемый и предрасположенный к ошибкам. Поведение программы становится менее очевидным. Вы пишите меньше кода, да, но код более хрупкий и менее понятный.

Пожалуйста, объясните новичку, что лучше: статическая типизация или динамическая?

Тогда почему говорят что JavaScript и Python заменит почти все языки, когда там есть такая существенная проблема?

Про Python так говорят только дураки.

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

Впрочем, динамическая типизация и там достала умных людей, поэтому JavaScript снабжают потихоньку инструментами из мира статической типизации. См. например, TypeScript

Ошибки делают неграмотные программисты.

Ошибки делаются не только из-за неграмотности, а еще и из-за невнимательности. А быть 100% сконцентрированным 100% рабочего времени — невозможно. Скажем, аварии совершают вполне опытные водители, и они явно это делают не нарошно.

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

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

код самодокументирует себя.

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

p.s. нормальные программисты пишут не только код, но и всё сопутствующее, если это не написали за них в задании.

Griboks, подсказки IntelliSense для динамических языков по очевидным причинам работают в IDE хуже чем для статических.

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

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

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

> нормальные программисты пишут не только код, но и всё сопутствующее, если это не написали за них в задании.

Знаете как вычисляется что вы начинающий?

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

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

Внутренние функции комментируют редко, тут избыточные комментарии скорее являются напротив — признаком некачественного кода не самоочевидного кода

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

InoMono, Знаете, как вычисляется, что вы плохой программист?
Хороший программист пишет простой и понятный код для любой базы, для любой платформы, для любой библиотеки. И этот код должен быть очевидным в исходниках, естественно. А плохой программист бла бла бла, а вот бла бла бла, а вот они бла бла бла и все делают бла бла бла, и вообще у меня за спиной стоит начальник, я пишу одной рукой и за каждый комментарий теряю зарплату, а на остальных мне бла бла бла.

Ну очень жаль, что вы оправдываетесь. Значит признаёте, что можете писать лучше, но вам лень.

Динамическая типизация плохая, потому что я слишком невнимательный.

Если бы какая-то типизация была заведомо лучшей — то на сегодня она осталась бы в гордом победившем одиночестве.

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

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

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

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

Поэтому сейчас все шире и шире применяется интересная модификация статической типизации — вывод типов. То есть вам не нужно явно прописывать тип переменной. Вы можете писать так же как и с динамической типизацией. Просто «новая переменная = что_то_там_вычислено_например_возврат_из_функции_или_выражение» и тип для этой новой переменной будет определен сам. Но в дальнейшем изменен он быть не может.

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

Динамическая типизация позволяет тебе делать ошибки

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

Поэтому сейчас все шире и шире применяется интересная модификация статической типизации — вывод типов.

Лекция 17. Типизация

Эффективное применение объектной технологии требует четкого описания в тексте системы типов всех объектов, с которыми она работает на этапе выполнения. Это правило, известное как статическая типизация (static typing), делает наше ПО: более надежным, позволяя компилятору и другим инструментальным средствам устранять несоответствия прежде, чем они смогут нанести вред; более понятным, обеспечивая точной информацией читателей: авторов клиентских систем и тех, кто будет сопровождать систему; более эффективным, поскольку информация о типах данных позволит компилятору сгенерировать оптимальный код. Хотя вопросами типизации данных активно занимались и вне объектной среды, да и сама статическая типизация применяется в языках, не поддерживающих ООП, особенно ярко эти идеи проявили себя именно при объектном подходе, во многом основанном на понятии типа, которое, сливаясь с понятием модуля, образует базовую ОО-конструкцию — класс.

Проблема типизации

Эффективное применение объектной технологии требует четкого описания в тексте системы типов всех объектов, с которыми она работает на этапе выполнения. Это правило, известное как статическая типизация (static typing), делает наше ПО:

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

[x]. более понятным, обеспечивая точной информацией читателей: авторов клиентских систем и тех, кто будет сопровождать систему;

[x]. более эффективным, поскольку информация о типах данных позволит компилятору сгенерировать оптимальный код.

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

О типизации при ОО-разработке можно сказать одно: эта задача проста в своей постановке, но решить ее подчас нелегко.


Базисная конструкция

Простота типизации в ОО-подходе есть следствие простоты объектной вычислительной модели. Опуская детали, можно сказать, что при выполнении ОО-системы происходят события только одного рода — вызов компонента (feature call):

означающий выполнение операции f над объектом, присоединенным к x, с передачей аргумента arg (возможно несколько аргументов или ни одного вообще). Программисты Smalltalk говорят в этом случае о «передаче объекту x сообщения f с аргументом arg«, но это — лишь отличие в терминологии, а потому оно несущественно.

То, что все основано на этой Базисной Конструкции (Basic Construct), объясняет частично ощущение красоты ОО-идей.

Из Базисной Конструкции следуют и те ненормальные ситуации, которые могут возникнуть в процессе выполнения:

Определение: нарушение типа

Нарушение типа в период выполнения или, для краткости, просто нарушение типа (type violation) возникает в момент вызова x.f (arg), где x присоединен к объекту OBJ, если либо:

[x]. не существует компонента, соответствующего f и применимого к OBJ,

[x]. такой компонент имеется, однако, аргумент arg для него недопустим.

Проблема типизации — избегать таких ситуаций:

Проблема типизации ОО-систем

Когда мы обнаруживаем, что при выполнении ОО-системы может произойти нарушение типа?

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

Статическая и динамическая типизация

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

[x]. Динамическая типизация: ждать момента выполнения каждого вызова и тогда принимать решение.

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

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

Термины типизированный и нетипизированный (typed/untyped) нередко используют вместо статически типизированный и динамически типизированный (statically/dynamically typed). Во избежание любых недоразумений мы будем придерживаться полных именований.

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

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

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

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

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

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

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

[x]. При объявлении каждой сущности или функции должен задаваться ее тип, например, acc: ACCOUNT. Каждая подпрограмма имеет 0 или более формальных аргументов, тип которых должен быть задан, например: put (x: G; i: INTEGER).

[x]. В любом присваивании x := y и при любом вызове подпрограммы, в котором y — это фактический аргумент для формального аргумента x, тип источника y должен быть совместим с типом цели x. Определение совместимости основано на наследовании: B совместим с A, если является его потомком, — дополненное правилами для родовых параметров (см. лекцию 14).

[x]. Вызов x.f (arg) требует, чтобы f был компонентом базового класса для типа цели x, и f должен быть экспортирован классу, в котором появляется вызов (см. 14.3).

Реализм

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

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

[x]. Совершенно некорректный язык, который легко создать, взяв любой существующий язык и добавив правило типизации, делающее любую систему некорректной. По определению, этот язык типизирован: так как нет систем, соответствующих правилам, то ни одна система не вызовет нарушения типов.

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

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

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

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

Пессимизм

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

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

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

Любой фактический родовой параметр 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), но отвечают на два разных вопроса:

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

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

[x]. Вопрос о связывании: когда мы должны знать, какую операцию инициирует данный вызов?

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

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

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

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

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

[x]. Ряд необъектных языков, скажем, Pascal и Ada, реализуют как статическую типизацию, так и статическое связывание. Каждая сущность представляет объекты только одного типа, заданного статически. Тем самым обеспечивается надежность решения, платой за которую является его гибкость.

[x]. Smalltalk и другие ОО-языки содержат средства динамического связывания и динамической типизации. При этом предпочтение отдается гибкости в ущерб надежности языка.

[x]. Отдельные необъектные языки поддерживают динамическую типизацию и статическое связывание. Среди них — языки ассемблера и ряд языков сценариев (scripting languages).

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

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

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

Следующий пример иерархии наследования поможет прояснить эти понятия:

Рис. 17.3. Виды летательных аппаратов

Вопрос о типизации: когда убедиться, что здесь будет компонент lower_landing_gear («выпустить шасси»), применимый к объекту (для COPTER его не будет вовсе) Вопрос о связывании: какую из нескольких возможных версий выбрать.

Статическое связывание означало бы, что мы игнорируем тип присоединяемого объекта и полагаемся на объявление сущности. В итоге, имея дело с Boeing 747-400, мы вызвали бы версию, разработанную для обычных лайнеров серии 747, а не для их модификации 747-400. Динамическое связывание применяет операцию, требуемую объектом, и это правильный подход.

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

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

Ковариантность и скрытие потомком

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

Но мир не прост. Объединение статической типизации с некоторыми требованиями программной инженерии создает проблемы более сложные, чем это кажется с первого взгляда. Проблемы вызывают два механизма: ковариантность (covariance) — смена типов параметров при переопределении, скрытие потомком (descendant hiding) — способность класса потомка ограничивать статус экспорта наследуемых компонентов.

Ковариантность

Что происходит с аргументами компонента при переопределении его типа? Это важнейшая проблема, и мы уже видели ряд примеров ее проявления: устройства и принтеры, одно- и двухсвязные списки и т. д. (см. разделы 16.6, 16.7).

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

Представим себе готовящуюся к чемпионату лыжную команду университета. Класс GIRL включает лыжниц, выступающих в составе женской сборной, BOY — лыжников. Ряд участников обеих команд ранжированы, показав хорошие результаты на предыдущих соревнованиях. Это важно для них, поскольку теперь они побегут первыми, получив преимущество перед остальными. (Это правило, дающее привилегии уже привилегированным, возможно и делает слалом и лыжные гонки столь привлекательными в глазах многих людей, являясь хорошей метафорой самой жизни.) Итак, мы имеем два новых класса: RANKED_GIRL и RANKED_BOY.

Рис. 17.4. Классификация лыжников

Для проживания спортсменов забронирован ряд номеров: только для мужчин, только для девушек, только для девушек-призеров. Для отображения этого используем параллельную иерархию классов: ROOM, GIRL_ROOM и RANKED_GIRL_ROOM.

Вот набросок класса SKIER:

— Выбрать в качестве соседа other.

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

Нас интересуют два компонента: атрибут roommate и процедура share, «размещающая» данного лыжника в одном номере с текущим лыжником:

При объявлении сущности other можно отказаться от типа SKIER в пользу закрепленного типа like roommate (или like Current для roommate и other одновременно). Но давайте забудем на время о закреплении типов (мы к ним еще вернемся) и посмотрим на проблему ковариантности в ее изначальном виде.

Как ввести переопределение типов? Правила требуют раздельного проживания юношей и девушек, призеров и остальных участников. Для решения этой задачи при переопределении изменим тип компонента roommate, как показано ниже (здесь и далее переопределенные элементы подчеркнуты).

Переопределим, соответственно, и аргумент процедуры share. Более полный вариант класса теперь выглядит так:

— Выбрать в качестве соседа other.

Аналогично следует изменить все порожденные от SKIER классы (закрепление типов мы сейчас не используем). В итоге имеем иерархию:

Рис. 17.5. Иерархия участников и повторные определения

Так как наследование является специализацией, то правила типов требуют, чтобы при переопределении результата компонента, в данном случае roommate, новый тип был потомком исходного. То же касается и переопределения типа аргумента other подпрограммы share. Эта стратегия, как мы знаем, именуется ковариантностью, где приставка «ко» указывает на совместное изменение типов параметра и результата. Противоположная стратегия называется контравариантностью.

Все наши примеры убедительно свидетельствуют о практической необходимости ковариантности.

[x]. Элемент односвязного списка LINKABLE должен быть связан с другим подобным себе элементом, а экземпляр BI_LINKABLE — с подобным себе. Ковариантно потребуется переопределяется и аргумент в put_right.

[x]. Всякая подпрограмма в составе LINKED_LIST с аргументом типа LINKABLE при переходе к TWO_WAY_LIST потребует аргумента BI_LINKABLE.

[x]. Процедура set_alternate принимает DEVICE-аргумент в классе DEVICE и PRINTER-аргумент — в классе PRINTER.

Ковариантное переопределение получило особое распространение потому, что скрытие информации ведет к созданию процедур вида

для работы с attrib типа SOME_TYPE. Подобные процедуры, естественно, ковариантны, поскольку любой класс, который меняет тип атрибута, должен соответственно переопределять и аргумент set_attrib. Хотя представленные примеры укладываются в одну схему, но ковариантность распространена значительно шире. Подумайте, например, о процедуре или функции, выполняющей конкатенацию односвязных списков (LINKED_LIST). Ее аргумент должен быть переопределен как двусвязный список (TWO_ WAY_LIST). Универсальная операция сложения infix «+» принимает NUMERIC-аргумент в классе NUMERIC, REAL — в классе REAL и INTEGER — в классе INTEGER. В параллельных иерархиях телефонной службы процедуре start в классе PHONE_SERVICE может требоваться аргумент ADDRESS, представляющий адрес абонента, (для выписки счета), в то время как этой же процедуре в классе CORPORATE_SERVICE потребуется аргумент типа CORPORATE_ADDRESS.

Рис. 17.6. Службы связи


Что можно сказать о контравариантном решении? В примере с лыжниками оно означало бы, что если, переходя к классу RANKED_GIRL, тип результата roommate переопределили как RANKED_GIRL, то в силу контравариантности тип аргумента share можно переопределить на тип GIRL или SKIER. Единственный тип, который не допустим при контравариантном решении, — это RANKED_GIRL! Достаточно, чтобы возбудить наихудшие подозрения у родителей девушек.

Параллельные иерархии

Чтобы не оставить камня на камне, рассмотрим вариант примера SKIER с двумя параллельными иерархиями. Это позволит нам смоделировать ситуацию, уже встречавшуюся на практике: TWO_ WAY_LIST > LINKED_LIST и BI_LINKABLE > LINKABLE; или иерархию с телефонной службой PHONE_SERVICE.

Пусть есть иерархия с классом ROOM, потомком которого является GIRL_ROOM (класс BOY опущен):

Рис. 17.7. Лыжники и комнаты

Наши классы лыжников в этой параллельной иерархии вместо roommate и share будут иметь аналогичные компоненты accommodation (размещение) и accommodate (разместить):

description: «Новый вариант с параллельными иерархиями»

accommodate (r: ROOM) is . require . do

Здесь также необходимы ковариантные переопределения: в классе GIRL1 как accommodation, так и аргумент подпрограммы accommodate должны быть заменены типом GIRL_ROOM, в классе BOY1 — типом BOY_ROOM и т.д. (Не забудьте: мы по-прежнему работаем без закрепления типов.) Как и в предыдущем варианте примера, контравариантность здесь бесполезна.

Своенравие полиморфизма

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

create b; create g;— Создание объектов BOY и GIRL.

Результат последнего вызова, вполне возможно приятный для юношей, — это именно то, что мы пытались не допустить с помощью переопределения типов. Вызов share ведет к тому, что объект BOY, известный как b и благодаря полиморфизму получивший псевдоним s типа SKIER, становится соседом объекта GIRL, известного под именем g. Однако вызов, хотя и противоречит правилам общежития, является вполне корректным в программном тексте, поскольку share -экспортируемый компонент в составе SKIER, а GIRL, тип аргумента g, совместим со SKIER, типом формального параметра share.

Схема с параллельной иерархией столь же проста: заменим SKIER на SKIER1, вызов share — на вызов s.accommodate (gr), где gr — сущность типа GIRL_ROOM. Результат — тот же.

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

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

Поэтому, не пытаясь натянуть контравариантную одежду на ковариантное тело, следует принять ковариантную действительность и искать пути устранения нежелательного эффекта.

Скрытие потомком

Прежде чем искать решение проблемы ковариантности, рассмотрим еще один механизм, способный в условиях полиморфизма привести к нарушениям типа. Скрытие потомком (descendant hiding) — это способность класса не экспортировать компонент, полученный от родителей.

Рис. 17.8. Скрытие потомком

Типичным примером является компонент add_vertex (добавить вершину), экспортируемый классом POLYGON, но скрываемый его потомком RECTANGLE (ввиду возможного нарушения инварианта — класс хочет оставаться прямоугольником):

Не программистский пример: класс «Страус» скрывает метод «Летать», полученный от родителя «Птица».

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

create r; — Создание объекта RECTANGLE.

Так как объект r скрывается под сущностью p класса POLYGON, а add_vertex экспортируемый компонент POLYGON, то его вызов сущностью p корректен. В результате выполнения в прямоугольнике появится еще одна вершина, а значит, будет создан недопустимый объект.

Корректность систем и классов

Для обсуждения проблем ковариантности и скрытия потомком нам понадобится несколько новых терминов. Будем называть классово-корректной (class-valid) систему, удовлетворяющую трем правилам описания типов, приведенным в начале лекции. Напомним их: каждая сущность имеет свой тип; тип фактического аргумента должен быть совместимым с типом формального, аналогичная ситуация с присваиванием; вызываемый компонент должен быть объявлен в своем классе и экспортирован классу, содержащему вызов.

Система называется системно-корректной (system-valid), если при ее выполнении не происходит нарушения типов.

В идеале оба понятия должны совпадать. Однако мы уже видели, что классово-корректная система в условиях наследования, ковариантности и скрытия потомком может не быть системно-корректной. Назовем такую ошибку нарушением системной корректности (system validity error).

Практический аспект

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

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

Далее мы будем затрагивать весьма тонкие и не столь часто дающие о себе знать аспекты объектного подхода. Читая книгу впервые, вы можете пропустить оставшиеся разделы этой лекции. Если вы лишь недавно занялись вопросами ОО-технологии, то лучше усвоите этот материал после изучения лекций 1-11 курса «Основы объектно-ориентированного проектирования», посвященной методологии наследования, и в особенности лекции 6 курса «Основы объектно-ориентированного проектирования», посвященной методологии наследования.

Корректность систем: первое приближение

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

Контравариантность и безвариантность

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

Оригинальность языка C++ в том, что он использует стратегию безвариантности (novariance), не позволяя менять тип аргументов в переопределяемых подпрограммах! Если бы язык C++ был строго типизированным языком, его системной типов было бы трудно пользоваться. Простейшее решение проблемы в этом языке, как и обход иных ограничений C++ (скажем, отсутствия ограниченной универсальности), состоит в использовании кастинга — приведения типа, что позволяет полностью игнорировать имеющийся механизм типизации. Это решение не кажется привлекательным. Заметим, однако, что ряд предложений, обсуждаемых ниже, будет опираться на безвариантность, смысл которой придаст введение новых механизмов работы с типами взамен ковариантного переопределения.

Использование родовых параметров

Универсальность лежит в основе интересной идеи, впервые высказанной Францем Вебером (Franz Weber). Объявим класс SKIER1, ограничив универсализацию родового параметра классом ROOM:

accommodate (r: G) is . require . do accommodation := r end

Тогда класс GIRL1 будет наследником SKIER1 [GIRL_ROOM] и т. д. Тем же приемом, каким бы странным он не казался на первый взгляд, можно воспользоваться и при отсутствии параллельной иерархии: class SKIER [G -> SKIER].

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

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

Типовые переменные

Ряд авторов, среди которых Ким Брюс (Kim Bruce), Дэвид Шенг (David Shang) и Тони Саймонс (Tony Simons), предложили решение на основе типовых переменных (type variables), значениями которых являются типы. Их идея проста:

[x]. взамен ковариантных переопределений разрешить объявление типов, использующее типовые переменные;

[x]. расширить правила совместимости типов для управления такими переменными;

[x]. считать язык (в остальном) безвариантным;

[x]. обеспечить возможность присваивания типовым переменным в качестве значений типы языка.

Подробное изложение этих идей читатели могут найти в ряде статей по данной тематике, а также в публикациях Карделли (Cardelli), Кастаньи (Castagna), Вебера (Weber) и др. Начать изучение вопроса можно с источников, указанных в библиографических заметках к этой лекции. Мы же не будем заниматься этой проблемой, и вот почему.

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

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

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

Полагаясь на закрепление типов

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

При описании классов SKIER и SKIER1 вас не могло не посетить желание, воспользовавшись закрепленными объявлениями, избавиться от многих переопределений. Закрепление — это типичный ковариантный механизм. Вот как будет выглядеть наш пример (все изменения подчеркнуты):

share (other: like Current) is . require . do

accommodate (r: like accommodation) is . require . do

Теперь потомки могут оставить класс SKIER без изменений, а в SKIER1 им понадобится переопределить только атрибут accommodation. Закрепленные сущности: атрибут roommate и аргументы подпрограмм share и accommodate — будут изменяться автоматически. Это значительно упрощает работу и подтверждает тот факт, что при отсутствии закрепления (или другого подобного механизма, например, типовых переменных) написать ОО-программный продукт с реалистичной типизацией невозможно.

Но удалось ли устранить нарушения корректности системы? Нет! Мы, как и раньше, можем перехитрить проверку типов, выполнив полиморфные присваивания, вызывающие нарушения системной корректности.

Правда, исходные варианты примеров будут отклонены. Пусть:

create b;create g;— Создание объектов BOY и GIRL.

Аргумент g, передаваемый share, теперь неверен, так как здесь требуется объект типа like s, а класс GIRL не совместим с этим типом, поскольку по правилу закрепленных типов ни один тип не совместим с like s, кроме него самого.

Впрочем, радоваться нам не долго. В другую сторону это правило говорит о том, что like s совместим с типом s. А значит, используя полиморфизм не только объекта s, но и параметра g, мы можем снова обойти систему проверки типов:

s: SKIER; b: BOY; g: like s; actual_g: GIRL;

create b; create actual_g — Создание объектов BOY и GIRL.

s := actual_g; g := s — Через s присоединить g к GIRL.

В результате незаконный вызов проходит.

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

Разрешим объявления вида like s лишь тогда, когда s описано как anchor. Изменим правила совместимости так, чтобы гарантировать: s и элементы типа like s могут присоединяться (в присваиваниях или передаче аргумента) только друг к другу.

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

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

тогда как у класса C — родителя D это выглядело

где Y соответствовало X, то теперь переопределение компонента r будет выглядеть так:

Остается только в классе D переопределить тип your_anchor.

Это решение проблемы ковариантности — полиморфизма будем называть подходом Закрепления (Anchoring). Более аккуратно следовало бы говорить: «Ковариация только через Закрепление». Свойства подхода привлекательны:

[x]. Закрепление основано на идее строгого разделения ковариантных и потенциально полиморфных (или, для краткости, полиморфных) элементов. Все сущности, объявленные как anchor или like some_anchor ковариантны; прочие-полиморфны. В каждой из двух категорий допустимы любые присоединения, но нет сущности или выражения, нарушающих границу. Нельзя, например, присвоить полиморфный источник ковариантной цели.

[x]. Это простое и элегантное решение нетрудно объяснить даже начинающим.

[x]. Оно полностью устраняет возможность нарушения системной корректности в ковариантно построенных системах.

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

[x]. Оно требует незначительного изменения языка, — добавляя одно ключевое слово, отраженное в правиле соответствия, — и не связано с ощутимыми трудностями в реализации.

[x]. Оно реалистично (по крайней мере, теоретически): любую ранее возможную систему можно переписать, заменив ковариантные переопределения закрепленными повторными объявлениями. Правда, некоторые присоединения в результате станут неверными, но они соответствуют случаям, которые могут привести к нарушениям типов, а потому их следует заменить попытками присваивания и разобраться в ситуации во время выполнения.

Казалось бы, дискуссию можно на этом закончить. Так почему же подход Закрепления не полностью нас устраивает? Прежде всего, мы еще не касались проблемы скрытия потомком. Кроме этого, основной причиной продолжения дискуссии является проблема, уже высказанная при кратком упоминании типовых переменных. Раздел сфер влияния на полиморфную и ковариантную часть, чем-то похож на результат Ялтинской конференции. Он предполагает, что разработчик класса обладает незаурядной интуицией, что он в состоянии для каждой введенной им сущности, в частности для каждого аргумента раз и навсегда выбрать одну из двух возможностей:

[x]. Сущность является потенциально полиморфной: сейчас или позднее она (посредством передачи параметров или путем присваивания) может быть присоединена к объекту, чей тип отличается от объявленного. Исходный тип сущности не сможет изменить ни один потомок класса.

[x]. Сущность является субъектом переопределения типов, то есть она либо закреплена, либо сама является опорным элементом.

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

При таком подходе переопределение типов и скрытие потомком — своего рода «предохранительный клапан», дающий возможность повторно использовать существующий класс, почти пригодный для достижения наших целей:

[x]. Прибегнув к переопределению типов, мы можем менять объявления в порожденном классе, не затрагивая оригинал. При этом чисто ковариантное решение потребует правки оригинала путем описанных преобразований.

[x]. Скрытие потомком защита от многих неудач при создании класса. Можно критиковать проект, в котором RECTANGLE, используя тот факт, что он является потомком POLYGON, пытается добавить вершину. Взамен можно было бы предложить структуру наследования, в которой фигуры с фиксированным числом вершин отделены от всех прочих, и проблемы не возникало бы. Однако при разработке структур наследования предпочтительнее всегда те, в которых нет таксономических исключений. Но можно ли их полностью устранить? Обсуждая ограничение экспорта в одной из следующих лекций, мы увидим, что подобное невозможно по двум причинам. Во-первых, это наличие конкурирующих критериев классификации. Во-вторых, вероятность того, что разработчик не найдет идеального решения, даже если оно существует.

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

Глобальный анализ

Этот раздел посвящен описанию промежуточного подхода. Основные практические решения изложены в лекции 17.

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

Типизация

Статическая и динамическая типизация

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

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

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

Термины типизированный и нетипизированный (typed/untyped) нередко используют вместо статически типизированный и динамически типизированный (statically/ dynamically typed ). Во избежание любых недоразумений мы будем придерживаться полных именований.

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


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

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

В литературе встречается термин » сильная типизация» (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 будет отклонен, как нарушающий правила. Так, компилятор отвергнет все нижеследующие операторы:

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

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

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

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

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

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

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

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

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

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

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

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

Наконец, эффективность может определять успех или отказ от объектной технологии на практике. В отсутствие статической типизации на выполнение 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 (сообщение). Но эта критика верна только для языков, ограниченных единичным наследованием .

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

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

Любой фактический родовой параметр 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 в данном вызове.

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

  • Ряд необъектных языков, скажем, Pascal и Ada, реализуют как статическую типизацию, так и статическое связывание . Каждая сущность представляет объекты только одного типа, заданного статически. Тем самым обеспечивается надежность решения, платой за которую является его гибкость.
  • Smalltalk и другие ОО-языки содержат средства динамического связывания и динамической типизации . При этом предпочтение отдается гибкости в ущерб надежности языка.
  • Отдельные необъектные языки поддерживают динамическую типизацию и статическое связывание . Среди них — языки ассемблера и ряд языков сценариев ( scripting languages ).
  • Идеи статической типизации и динамического связывания воплощены в нотации, предложенной в этой книге.

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

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

Следующий пример иерархии наследования поможет прояснить эти понятия:

Вопрос о типизации: когда убедиться, что здесь будет компонент lower_landing_gear («выпустить шасси «), применимый к объекту (для COPTER его не будет вовсе) Вопрос о связывании: какую из нескольких возможных версий выбрать.

Статическое связывание означало бы, что мы игнорируем тип присоединяемого объекта и полагаемся на объявление сущности. В итоге, имея дело с Boeing 747-400, мы вызвали бы версию, разработанную для обычных лайнеров серии 747, а не для их модификации 747-400. Динамическое связывание применяет операцию, требуемую объектом, и это правильный подход.

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

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

Записки программиста

Так ли плоха динамическая типизация?

В последнее время наблюдается большой интерес к языкам программирования со статической типизацией. Зачастую тот факт, что в неком языке (Scala, Haskell, OCaml, …) используется статическая типизация, преподносится в качестве неоспоримого преимущества этого языка над языками программирования с динамической типизацией (Erlang, Clojure и другие лиспы, Perl, Python, Ruby, …). Здесь имеет место явная подмена понятий, манипуляция неокрепшим сознанием неопытных программистов, троллинг, пропаганда и другие нехорошие вещи.

Обратим внимание, что в программировании есть две огромные ниши.

Первую нишу можно условно назвать «низкоуровневой». Сюда относится разработка драйверов, операционных систем, приложений для микроконтроллеров на хим-заводах, виртуальных машин, архиваторов/кодеков, большинства СУБД, часто — компиляторов и отладчиков, а также разработка веб-браузеров и движков трехмерных компьютерных игр. В общем, это все те области, где неистово рулят C/C++ и в обозримом будущем переход с них на что-то другое не предвидится. Поскольку эти языки являются слишком низкоуровневыми и все равно не обладают правильной статической типизацией, «низкоуровневая» ниша в контексте данного поста нам не интересна.

Есть и совершенно другая ниша, которую, как вы, скорее всего, уже догадались, мы условно назовем «высокоуровневой». Сюда относятся разработка большинства десктоп-приложений, сайтиков (включая фронтенд), мобильных приложений, игр типа Angry Birds, демонов, мало занимающихся вычислениями, некоторых СУБД, различного рода искусственных интеллектов, и так далее. В этой нише выбор языков программирования намного богаче, а потому интересно порассуждать о применимости языков со статической и динамической типизацией.

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

Поговорим о скорости. Вообще-то говоря, нет связи между скоростью работы программы и тем, используется ли в языке, на котором эта программа написана, статическая или динамическая типизация. За примерами далеко ходить не надо — взять хотя бы SBCL (это такой компилятор Common Lisp), PyPy ( JIT-компилятор Python’а в машинный код) или V8 (почти как PyPy, только для JavaScript). Программы на Common Lisp могут дробить числа не хуже Java, в связи с чем первый аргумент за статическую типизацию, как минимум, сомнителен. Отметим, что некорректно сравнивать быстродействие программ, написанных на CL и C++, поскольку эти языки используются для решения задач, относящихся к разным нишам.

Также аргумент относительно скорости сомнителен по совершенно другой причине. Действительно ли в «высокоуровневой» нише часто требуется умение быстро дробить числа? На самом деле, десктопные приложения все равно большую часть времени ожидают ввода каких-то данных пользователем или ответа от СУБД. Большинство сайтов написаны на чем-то вроде PHP или Perl, и, по всей видимости, скорость этих языков всех устраивает. Опять же, потому что большую часть времени они всего лишь парсят формочки и работают с базой данных.

Хорошо, а что на счет статической проверки типов? На самом деле, она и вправду весьма удобна. Но проблема в том, что помимо класса ошибок, от которых нас избавляет статическая типизация, есть еще великое множество ошибок. Дэдлоки, состояние гонки, ошибки округления, целочисленное переполнение, утечки памяти, выход за границу массива, sql-injection и многие другие. Да и элементарные ошибки при реализации бизнес-логики никто не отменял. Чтобы обезопасить приложение от этих ошибок, его нужно тестировать. А поскольку тестировать вручную достаточно большое приложение занимает много времени, тесты должны быть автоматизированы. Возникает закономерный вопрос. Если нам все равно необходимо писать тесты, зачем нужна статическая проверка типов?

Примечание: Занятный вопрос — может ли статическая типизация повысить качество продукта, разрабатываемого командой программистов, которые не пишут тесты? Лично я в этом сомневаюсь. Культура разработки либо есть, с code review, тестированием, непрерывной интеграцией и другими хорошими практиками, и тогда продукт получается неплохим, либо ее нет. Если команда не пишет «эти глупые тесты в стиле 2 + 2 = 4», зато практикует «тестирование в бою», релизы раз в год, имена переменных X1, X2, …, XN, а также другие ужасные вещи, на выходе получится говно. В обоих случаях не играет никакой роли, пишется продукт на Scala или на PHP.

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

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

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

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

  • Языки с динамической типизацией быстрее компилируются, ежели компиляция вообще предусмотрена, что ощутимо ускоряет разработку;
  • Почему-то языки с динамической типизацией часто имеют более читаемые сообщения об ошибках, нежели языки со статической типизацией (вроде «программа упала, потому что класс Int не имеет метода toLower, файл string_parser.code, строка 193, переменная StrVariable, стектрейс: …»);
  • Обычно в языках с динамической типизацией также предусмотрен удобный мокинг, что существено упрощает написание тестов;
  • В Erlang, например, вы можете подсоединиться к работающему приложению и выполнить в нем некий отрывок кода с целью исправить что-то или запустить трассировщик с целью разобраться в обнаруженной баге;

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

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

Почему динамическая типизация лучше статической типизации.

Статическая типизация — это когда типы определяются заранее и строго проверяются на этапе компиляции (или чего там вместо компиляции — короче, парсинга). В ОО-языках типы — это классы. Например, в Java:

Мы видим, что перед названием переменной (‘hello’), которая инициализируется литералом-объектом String, обязательно указывается его класс (т.е. String). И если написать, скажем, вот так:

— компилятор будет мерзко ругаться, потому что класс объявленной переменной не совпадает с классом литерала. И, казалось бы, это правильно — какой смысл присваивать переменной класса Integer строку? Но это только кажется. Вот как, например, в Smalltalk:

Как видите, идентификатор hello уже не объявлен каким-либо конкретным классом. Он сам по себе. Ему можно посылать сообщения (вызывать методы), и, если класс самого объекта их определяет, они будут исполнены. А если нет, то не будут (в этом случае вызывается определённый в классе Object метод doesNotUnderstand: aMessage, который кидает исключение. Но это поведение можно переопределить каким угодно способом.)

Есть мнение, что статическая типизация полезна, поскольку помогает избежать глупых ошибок. Например, если вызвать метод, который не объявлен в каком-либо классе, это будет ясно уже на этапе компиляции, а не в процессе выполнения программы (в не самом очевидном месте). Но: умные программисты редко допускают глупые ошибки. А если и допускают, то на это есть юнит-тесты. Вот вы (да, вы!) пишете юнит-тесты? Нет? А зря. Пишите. Ошибки бывают не только по причине неверно написанного названия метода, но и в тысячах других случаев. И юнит-тесты способствуют тому, что ошибка выскочит сразу, а не в процессе эксплуатации уже написанной программы. В этом их, юнит-тестов, главная польза. Ну, а если мы их всё равно пишем, то устранение возможных ошибок статической типизацией — глупость, поскольку такие ошибки выявляются и устраняются при первом же прогоне теста.

Зато статическая типизация многое усложняет. Например, из-за неё становится необходимым преобразование классов (кастинг). О, этот кастинг! Ну скажите честно, неужели вам действительно доставляет удовольствие делать так (Java)?

Сравните со Smalltalk:

То-то же. Что-что? Вы говорите, в JSDK 1.5 появились Generics? Ну и что? Они упрощают (и то не факт) только работу с коллекциями, а общая-то проблема остаётся. Вот, кстати, опять же, интерфейсы. Сейчас идеологи Java говорят, что надо программировать, используя не классы, а интерфейсы (мол, в случае чего, можно всегда подставлять одну имплементацию вместо другой). И, соответственно, на каждое определение поведение объекта нужен как минимум один класс и один интерфейс (это же совсем немного, в EJB и вовсе надо по три интерфейса на объект, и ничего, писали!) Чушь. Динамическая типизация устраняет и потребность в интерфейсах. Если нет необходимости проверять тип, какой же простой становится жизнь! И любой объект можно подставить вместо любого другого объекта — лишь бы он понимал тот же набор сообщений. Здорово, да? Поэтому в Smalltalk нет интерфейсов. Справедливости ради следует сказать, что в нём есть протоколы, то есть общие наборы сообщений, но это не одна из структур языка, а всего лишь средство категоризации библиотеки классов.

И, заметьте, я ещё ничего не говорил про динамически определяемые методы и классы, которые становятся необходимыми при решении хоть сколько-нибудь интересных программисту задач. Вот вы в Java можете создать класс с нуля динамически, без применения чОрной магии (Reflection API к ней, безусловно, относится, хотя и с ним не всё так просто)? И я не могу. А в Smalltalk, Perl и Javascript — запросто. Что, к примеру, позволяет следующие вольности при работе с XML:

Типизация. Что это и какие её виды бывают в программировании

Primary tabs

Forums:

Типизация — способ (порядок, набор правил) разделения данных на типе в конкретном языке программирования.

Виды типизации

Обычно типизацию описывают несколькими словами, например, говорят, что

PHP обладает слабой, динамической типизацией

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

Сильная / слабая типизация (тоже самое, что строгая / нестрогая)

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

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

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

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

Например, Pascal относится к языкам с сильной типизацией (например поэтому в Паскале нельзя конкатенировать строки и числа), но тем не менее возможно, например преобразование целого числа к вещественному в неявном виде, подобное явление называют «ограниченное неявное преобразование» (подробнее см. источник 1).

  • Сильная типизация: Java, Python, Haskell, Pascal, Lisp;
  • Слабая типизация: C, JavaScript, Visual Basic, PHP.

Статическая / динамическая типизация

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

В динамической типизации все типы выясняются уже во время выполнения программы.

  • Статическая: Pascal, C, Java, C#;
  • Динамическая: Python, JavaScript, Ruby, PHP

Явная / неявная типизация

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

  • Явная: C++, D, C#
  • Неявная: PHP, Lua, JavaScript

Выводы о типизации конкретного ЯП

Как уже говорилось выше, для типизации языка используют несколько слов («набор видов типизации»), так в соответствии с приведенными выше типами, можно подробнее охарактеризовать PHP:

PHP обладает слабой, динамической, неявной типизацией.

Также не забудем и про Pascal:

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

При этом существуют языки программирования исключения вообще нормально по данной системе (относительно подходящей для большинства ЯП), напр. такие что обладают одновременно и статической и динамической типизацией (хотя, наверное, для них надо просто придумать третий типа в виде «статическая/динамическая», что-то вроде: «стато-динамическая типизация» 😉

Мастер Йода рекомендует:  Циклический сдвиг вправо на n на PHP
Добавить комментарий