Страницы

Поиск по вопросам

понедельник, 9 декабря 2019 г.

Типизация - языки со “множественной типизацией” (union types) аргументов методов?

#php #ооп #любой_язык #теория_типов


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

Комментариев нет:

Отправить комментарий