Всем привет! В этой статье мы рассмотрим, как дженерики помогают писать гибкий и повторно используемый код, позволяя функциям, классам и интерфейсам работать с различными типами. Пожалуй, это база, без которой нельзя обойтись в любом языке программирования, но нас сейчас интересует только Dart.
Универсальные классы
Для определения универсального класса в Dart нам понадобится только его имя и параметр, заключенный в угловые скобки (в нашем случае это <T>):
void main() {
final ClassName<int> intClass = ClassName<int>(73);
final ClassName<String> stringClass = ClassName<String>('Hello, Dart!');
final dynamic dynamicValue = <String, dynamic>{'key' : someStoredValue}['key'];
final ClassName<dynamic> dynamicClass = ClassName(dynamicValue);
}
Универсальные методы
Для определения универсального метода используется аналогичный синтаксис.
T create<T>() { ... }
void main() {
final int intValue = create<int>();
final dynamic dynamicValue = create();
}
Ограничения на типы
К параметрам типам можно применять ограничения. Для этого используется знакомое слово extends
. Давайте ограничим ранее созданную функцию на типе num
.
T create<T extends num>() { ... }
void main() {
final int intValue = create<int>();
final num numValue = create();
final String stringValue = create<String>(); // Не скомпилируется
}
Теперь наша функция может принимать только подтипы num (int и double) и сам тип ограничения. Функция попросту не позволит использовать ее с параметром типа, который не является наследником ограничения. Ранее я говорил, что если не указать тип, то автоматически подставляется dynamic
, но это касается случая, когда у нас нет ограничения. В нашем случае типом по умолчанию будет тип ограничения.
Ограничения (уже другие)
Дженерики в ЯП, включая Dart, имеют определенные ограничения и запреты. Вот несколько ключевых моментов, о которых следует знать:
Стирание типов: Dart использует стирание типов, что означает, что общие типы стираются во время выполнения (runtime). Конечно, это делается из соображений производительности, но это также означает, что мы не можем получить доступ к информации о типах во время выполнения;
Ограничение на ограничения типа: Dart не позволяет использовать более одного аргумента для extends;
Производительность: дженерики могут вносить дополнительные расходы из-за проверок типов и динамической диспетчеризации.

Универсальные коллекции
Все коллекции Dart являются универсальными и принимают любой параметр типа, поэтому возможности безграничны, а плюсы использования также прекрасны, но их сильно касается один из недостатков дженериков, а именно - стирание типов. Рассмотрим небольшой пример, где у нас есть два списка строк, но с разным объявлением.
bool isListOfType<T>(List<dynamic> list) {
// Способ №1
return list.every((dynamic element) => element is T);
// Альтернативный способ №2
// return list.whereType<String>().length == list.length;
// Альтернативный способ №3 - НЕ ДЕЛАТЬ ТАК!
// return list.every((dynamic element) => element.runtimeType == T);
}
void main() {
final List<String> stringList = <String>['Hello', 'Dart'];
final List<dynamic> dynamicStringList = <dynamic>['Hello', 'Dart'];
print('${stringList is List<String>} - ${isListOfType<String>(stringList)}'); // true - true
print('${dynamicStringList is List<String>} - ${isListOfType<String>(dynamicStringList)}'); // false - true
print('${stringList.runtimeType == List<String>}'); // true
}
Обычно для проверки на принадлежность типу используется оператор is
, но с коллекциями все не так просто. Казалось бы, внутри переменной dynamicStringList находятся только строковые значения, но почему эе проверка на принадлежность не работает? Дело в том, что List<String>
не подтип List<dynamic>
, поэтому если нужно проверить коллекцию на принадлежность типа, то самый верный вариант - перебор элементов с одним из предложенных вариантов. Способ №3 показывает как можно, но не стоит делать, так как runtimeType
используется только для дебаг целей и может отражать не реальный тип, а внутренний.
Обратной совместимости также не завезли, поэтому стоит запомнить следующее:
void main() {
final List<String> strings = <String>['Dart'];
final List<dynamic> anything = strings; // OK
final List<String> anythingCopy = anything; // Не скомпилируется
anything.add(123); // Ошибка! Мы сломали список строк
}

Парсинг dynamic в коллекцию
Рассмотрим еще один пример, где нам нужно реализовать хранилище объектов в своем приложении, которое будет перманентным, но не строгим. Будем использовать SharedPreferences
, который для оптимизации под капотом использует Map<String, Object>
для быстрого доступа к значениям (в runtime). Чтобы было удобнее работать с классом хранилища определим класс-обертку PreferencesEntry
.
class PreferencesEntry<T extends Object> {
const PreferencesEntry({
required String key,
required SharedPreferences storage,
}) : _key = key,
_storage = storage;
final String _key;
final SharedPreferences _storage;
T? get value {
final Object? value = _storage.get(_key);
print('require type - $T; actual type - ${value.runtimeType}; value - $value');
return value as T?;
}
void setValue(T? value) => _storage.set(_key, value);
}

Если с базовыми типами все понятно, то как быть со списком строк? Тут не все просто, поскольку опять упираемся в ограничения.
Проблема та же, что указывалась выше: List<String>
не подтип для List<Object?>
. Попробуем исправить код.
T? get value {
final Object? value = _storage.get(_key);
if (T != List<String>) return value as T?;
List<dynamic>? list = value as List<dynamic>?;
if (list != null && list is! List<String>) {
list = list.cast<String>().toList(growable: false);
}
return list as T?;
}
Если объект не List<String>
, то мы используем cast, который создаёт новый список, элементы которого интерпретируются как String
. Это нужно, чтобы избежать потенциальных ClassCastException.
Выводы
Универсальные типы являются неотъемлемой частью разработки, обеспечивают строгую проверку типов, гарантируя правильную обработку типов данных во время компиляции, однако стоит помнить не только о преимуществах, но и недостатках.