Я не знаю, есть ли конкретный термин для этой проблемы, но есть три основных класса решений:
- избегать конкретных типов в пользу динамической отправки
- разрешить параметры типа заполнителя в ограничениях типа
- избегайте параметров типа, используя связанные типы / семейства типов
И, конечно, стандартное решение: продолжайте указывать все эти параметры.
Избегайте конкретных типов.
Вы определили Iterable
интерфейс как:
interface <Element> Iterable<T: Iterator<Element>> {
getIterator(): T
}
Это дает пользователям интерфейса максимальную мощность, потому что они получают точный конкретный тип T
итератора. Это также позволяет компилятору применять больше оптимизаций, таких как встраивание.
Однако, если Iterator<E>
это динамически отправляемый интерфейс, то знание конкретного типа не требуется. Это, например, решение, которое использует Java. Интерфейс был бы тогда написан как:
interface Iterable<Element> {
getIterator(): Iterator<Element>
}
Интересной вариацией этого является impl Trait
синтаксис Rust, который позволяет вам объявлять функцию с абстрактным типом возврата, но зная, что конкретный тип будет известен на сайте вызова (что позволяет оптимизировать). Это ведет себя подобно неявному параметру типа.
Разрешить параметры типа заполнителя.
Iterable
Интерфейс не нужно знать о типе элемента, так что можно было бы написать это как:
interface Iterable<T: Iterator<_>> {
getIterator(): T
}
Где T: Iterator<_>
выражает ограничение «T - любой итератор, независимо от типа элемента». Более строго, мы можем выразить это как: «существует некоторый тип, Element
который T
есть Iterator<Element>
», без необходимости знать какой-либо конкретный тип для Element
. Это означает, что выражение типа Iterator<_>
не описывает фактический тип и может использоваться только как ограничение типа.
Используйте семейства типов / связанные типы.
Например, в C ++ тип может иметь члены типа. Это обычно используется в стандартной библиотеке, например std::vector::value_type
. Это на самом деле не решает проблему параметров типа во всех сценариях, но, поскольку тип может ссылаться на другие типы, один параметр типа может описывать целое семейство связанных типов.
Давайте определим:
interface Iterator {
type ElementType
fn next(): ElementType
}
interface Iterable {
type IteratorType: Iterator
fn getIterator(): IteratorType
}
Затем:
class Vec<Element> implement Iterable {
type IteratorType = VecIterator<Element>
fn getIterator(): IteratorType { ... }
}
class VecIterator<T> implements Iterator {
type ElementType = T
fn next(): ElementType { ... }
}
Это выглядит очень гибко, но обратите внимание, что это может усложнить выражение ограничений типа. Например, как написано Iterable
, не применяется ни один тип элемента итератора, и мы могли бы interface Iterator<T>
вместо этого объявить . И вы сейчас имеете дело с довольно сложным исчислением типов. Очень легко случайно сделать такую систему типов неразрешимой (или, может быть, она уже есть?).
Обратите внимание, что связанные типы могут быть очень удобны в качестве значений по умолчанию для параметров типа. Например, если предположить, что Iterable
интерфейсу нужен отдельный параметр типа для типа элемента, который обычно, но не всегда совпадает с типом элемента итератора, и что у нас есть параметры типа заполнителя, можно сказать:
interface Iterable<T: Iterator<_>, Element = T::Element> {
...
}
Однако это всего лишь функция эргономики языка, которая не делает язык более мощным.
Системы типов сложны, поэтому стоит взглянуть на то, что работает, а что нет на других языках.
Например, подумайте о прочтении главы « Расширенные черты» в Rust Book, в которой обсуждаются связанные типы. Но учтите, что некоторые моменты в пользу связанных типов вместо общих применяются только там, потому что язык не имеет подтипов, и каждая черта может быть реализована не более одного раза для каждого типа. Т.е. черты Rust не являются Java-подобными интерфейсами.
Другие интересные системы типов включают в себя Haskell с различными языковыми расширениями. Модули / функторы OCaml являются сравнительно простой версией семейств типов, без непосредственного смешения их с объектами или параметризованными типами. Java отличается ограничениями в своей системе типов, например, обобщениями с удалением типов и отсутствием обобщений над типами значений. C # очень похож на Java, но ему удается избежать большинства этих ограничений за счет увеличения сложности реализации. Scala пытается интегрировать дженерики в стиле C # с классами типов в стиле Haskell поверх платформы Java. Обманчиво простые шаблоны C ++ хорошо изучены, но не похожи на большинство реализаций дженериков.
Также стоит взглянуть на стандартные библиотеки этих языков (особенно на стандартные коллекции библиотек, такие как списки или хеш-таблицы), чтобы увидеть, какие шаблоны обычно используются. Например, C ++ имеет сложную систему различных возможностей итератора, а Scala кодирует возможности детального сбора в качестве признаков. Стандартные интерфейсы библиотеки Java иногда не работают, например Iterator#remove()
, но могут использовать вложенные классы как своего рода связанный тип (например Map.Entry
).