В качестве языка примера буду использовать PHP , для простоты. Поясню сразу суть
вопроса. В современных языках, особенно это касается интерпритируемых, есть типизация
традиционно не строгая:
function join($a){
return is_array($a) ? implode('', $a) : $a;
}
А есть типизация строгая:
function join(array $a) : string{
return implode('', $a);
}
Есть ещё, например в C++ шаблонная типизация:
template
R* join(T * array, int count)
{
R *output = new R(count);
for (int i = 0; i < count; i++)
output += array[i] ;
return output;
}
Есть ещё в C++ перегрузка по типам, но она весьма не гибка и громоздка, как и шаблонная
типизация, последняя вдобавок ещё и не читаема.
Но очень не хватает типизации множественной (как я узнал - это называется union types)
- хотелось бы следующее:
function join(array|Iterator|ICollectable $input) : string|null{
/*какой-то код обработки*/
}
Вопрос - есть ли языки, поддерживающие подобный синтаксис ограничения по типам, если
да, то какие? Если нет, то каковы причины, чем концепция плоха?
Почему возникает вопрос отсутствия встроенной поддержки сего - программисты языков
с variance-типами, обычно пишут в коментах метода что-то вроде:
/**
* Some function describe
*
* @param array|ISomeList $list ...
* @return false|string ...
*/
Но в объявлении ф-ии они вынуждены не делать явной отсылки к типу(полагаясь на комментарии),
оставляя variance типы:
public function someFunction($list) {... return $some;};
Это встречается регулярно в более чем серьёзных фреймворках, которые не могут принебречь
читаемостью кода ради строгой типизации.
В итоге при исследовании кода без явного указания желаемых типов: понижается читаемость.
Комменты не дают 100% гарантии ограничения типов: программист, как и IDE не может точно
знать - что может придти во входном параметре. А при работе программы - если вдруг
во входной параметр попало не то что нужно: не происходит ошибок/предупреждений , что
усложняет ловлю ошибок, понижает стабильность.
Наоборот же при использовании только строгих типов - безальтернативно усложняется
вся система в целом: количество классов растёт геометрично. Что ещё хуже, ведь НЕ-строгость
типов работает на простые архитектурные решения и читаемый код, и в случае скриптовых
языков ей нельзя принебрегать.
UPD:
Да, конечно такая фича не совсем ложится на принципы ООП. Но это было бы поводом
эти принципы пересматривать - т.к. они очень обходчиво не учитывают "геометрический
рост количества классов" . А также union-типизация работает совместно с утиной типизацией
- что я бы минусом не назвал, но это большое поле для споров.
UPD2:
Если возникает вопрос обоснованности мульти-типов: Более живой пример из PHP - представлено
объявление важной, часто используемой функции выборщика объектов из БД-таблиц (db-table-маппера)
Zend фреймворка (1.9) класс Zend_Db_Table_Abstract .
/**
* Fetches all rows.
*
* Honors the Zend_Db_Adapter fetch mode.
*
* @param string|array|Zend_Db_Table_Select $where OPTIONAL An SQL WHERE clause
or Zend_Db_Table_Select object.
* @param string|array $order OPTIONAL An SQL ORDER clause.
* @param int $count OPTIONAL An SQL LIMIT count.
* @param int $offset OPTIONAL An SQL LIMIT offset.
* @return Zend_Db_Table_Rowset_Abstract The row results per the Zend_Db_Adapter
fetch mode.
*/
public function fetchAll($where = null, $order = null, $count = null, $offset = null)
{
if (!($where instanceof Zend_Db_Table_Select)) {
$select = $this->select();
if ($where !== null) {
$this->_where($select, $where);
}
if ($order !== null) {
$this->_order($select, $order);
}
if ($count !== null || $offset !== null) {
$select->limit($count, $offset);
}
} else {
$select = $where;
}
$rows = $this->_fetch($select);
$data = array(
'table' => $this,
'data' => $rows,
'readOnly' => $select->isReadOnly(),
'rowClass' => $this->getRowClass(),
'stored' => true
);
$rowsetClass = $this->getRowsetClass();
if (!class_exists($rowsetClass)) {
Zend_Loader::loadClass($rowsetClass);
}
return new $rowsetClass($data);
}
Первый аргумент по замыслу разработчика может иметь один из нескольких типов. Это
нужно, чтобы потенциальному пользователю метода(программисту) было очень просто уместить
в голове маппер. Ведь он может вызвать из кода этот метод по-разному, но метод при
этом остаётся тот-же:
$table->fetchAll('user_id in (1,2,5)');
$table->fetchAll(['user_id < 100', 'gender = "male"']);
$table->fetchAll( $table->select()->where('user_id IN (?)', $userIdsArray) );
Один метод легко запомнить, это хорошо ложится в голову. В то время, как программисту
метода fetchAll не хочется размазывать логику одной задачи (выбора объектов из базы
данных) на несколько перегруженных методов внутри себя - он хочет чтобы всё было в
одном месте, чтобы открывший код понял сразу что и в каком порядке работает. Как минимум
это соответствует SRP - обязанность у метода одна: выбрать из БД объекты.
То есть предоставляется целый язык для общения с БД-таблицей. Это больше сервис для
программиста, нежели просто метод. И такие методы во фреймворках: каждый второй, потому
что фреймворк должен быть прежде всего удобным программисту. Программист не должен
вникать в "тысячи классов" - чтобы пользоватся фичей - вот это яркий пример решения
вопроса читаемости кода, в котором пригодились бы union types.
UPD3:
Оказывается разработчики PHP, как подсказали в коментах, тоже думали ввести эту концепцию
в 2015 году, но на голосовании - отклонили.
Ответы
Ответ 1
Интересный вопрос. Не знаю как в других языках обстоят дела, но в scala то что вы
хотите сделать, решается так:
Использованием pattern matching
def f1(obj: Any) = obj match {
case x: Array[String] => println("is array")
case x: String => println("is string")
case _ => println("")
}
Так же, есть возможность определить какие методы должны быть у типа аргумента. Например.
case class A() {
def m1() = "m1 is invoked"
def m2() = println("m2 is invoked") }
def f2(obj: {def m1(): String}) = {
println(obj.m1())
}
val obj = A()
f2(obj)
Ответ 2
Любая конструкция должна иметь обоснование: зачем это нужно, какие плюсы оно дает,
какие минусы оно дает.
Из теории проектирования мы знаем, что программная единица должна выполнять строго
отведенную под нее задачу-алгоритм. Наличие подобной семантики подразумевает, что код
будет как называется "и жнец, и чтец, и на дуде игрец". Это полностью рушит логику
и порождает плохой стиль программирования. К тому же появляется очень много вопросов
по поводу его использования.
К примеру, что ожидать от него в ответ? И как потом с полученным предлагаете работать?
int|bool|string someValueResult = goodNews(); ???
Это получается, что бы удостовериться в полученном результате, я должен сделать еще
двадцать проверок действительно ли мне вернули строку или может быть массив? Или как
предлагается работать внутри кода? Опять же, я не думаю, что есть алгоритмы которые
умеют хорошо и гармонично работать с разнообразными типами. Все равно придется вернуться
к комментариям и прочитать, что делает код и как.
Если уж так хочется, проще сделать собственные классы или методы, который будет разруливать
типы внутри и выдавать TypeError, чем гадать, что же на самом деле попало к нам в метод
и как с этим работать. И проще всегда работать с чем-то унифицированным.
Плюсов в общем-то почти не вижу, без этого можно спокойно обойтись.
Ответ 3
Насколько я понимаю, в указании этого только на уровне документации беда в том, что
написанное там непроверяемо или проверяемо очень тяжело: требует дополнительного разбора,
статического анализа или ещё каких-то дополнительных этапов.
Я иногда встречаю в clojure в этой роли протоколы.
Протокол это абстракция, которая содержит несколько сигнатур методов, а в её документации
пишутся указания и ограничения по их реализации.
Пока что похоже на интерфейсы. И объявление протокола действительно приводит к созданию
одноимённого интерфейса, который можно реализовать кодом не на Clojure, а, скажем, на Java.
А теперь весёлая часть: реализовывать интерфейс может любой тип, и этот факт может
быть объявлен где угодно. В самом интересном случае вы можете реализовать поддержку
протокола из одной библиотеки типом из другой библиотеки в собственном коде. Без вмешательства
в код библиотек! Реализовывать протоколы можно даже встроенными типами!
Фактически выходит, что проверка на тип протокола автоматически проверяет объект
на принадлежность тип-сумме* реализаций (правда, в рантайме: надо писать тесты). Но
с возможностью добавить туда что-то ещё.
В ООП это аналогично набору классов-обёрток с общим предком или интерфейсом. Значение
при этом нужно заворачивать в обёртку до передачи по месту использования, вручную,
поскольку на том этапе ещё неизвестно, какую обёртку выбрать. Clojure делает это сам,
следуя типу значения. Просто синтаксический сахар, никакого волшебства. Но, в конце
концов, мы о языковых средствах и говорим.
И, конечно, есть разного рода ассерты. Некоторые из них могут автоматически исключаться
при компиляции в релиз. Они обычно выражают то же, что и в документации, но в проверяемом
машиной виде. Может показаться минусом тот факт, что за документацией нужно лезть в
исходники, но... может, тот факт, что документация и код не разделены, это даже плюс?
Банальное предусловие (и единственное встроенное в язык из представленных решений):
(defn lookup [arg]
{:pre [(or (vector? arg)
(isa? arg String)
(isa? arg Integer))]}
(...реализация...))
Есть core.typed, дополнительная "статическая типизация" (работающая больше как тесты,
не при компиляции, а только при явном запуске), там просто есть функция U, принимающая
набор типов и возвращающая их тип-сумму*. Это кладётся в аннотацию, рядом с реализацией,
хотя можно положить и отдельно. Правда, core.typed так и остался нишевой "игрушкой",
которой надо аннотировать вообще всё, иначе от проверок мало толку, а вывод типов работает
плохо.
(t/ann lookup [(t/U t/Str
(t/Vec t/Any)
t/Int ) -> DbResult])
;; А реализация где-то в другом месте
Схемы, самый популярный вариант из них это prismatic/schema. Там either, прямой аналог
U из core.typed, признан устаревшим, т. к. он был медленным и его нарушения выдавали
странные сообщения об ошибках. Частично его функции теперь выполняет cond-pre (и наш
случай тоже):
(s/defn lookup [arg :- (s/cond-pre s/Str
[s/Any]
s/Int)]
(...реализация...))
Это компактный вариант. Можно результат cond-pre записать куда-то и делать по нему
validate/check явно.
Необходимо, конечно, чтобы проверки запускались. В тестах, например. Это отдельный
запуск, и можно было бы с тем же успехом разбирать комментарии и делать проверки на
их основе, но это технически сложнее.
*тип-сумма это операция над типами, возвращающая тип, под который подходят значения
всех составляющих типов.