Что такое дженерики какую проблему они решают
Перейти к содержимому

Что такое дженерики какую проблему они решают

  • автор:

Generics

Начиная с JDK 1.5, в Java появляются новые возможности для программирования. Одним из таких нововведений являются Generics. Generics являются аналогией с конструкцией «Шаблонов»(template) в С++, но имеет свои нюансы. Generics позволяют абстрагировать множество типов. Наиболее распространенными примерами являются Коллекции.

Вот типичное использование такого рода (без Generics):

1. List myIntList = new LinkedList(); 2. myIntList.add(new Integer(0)); 3. Integer x = (Integer) myIntList.iterator().next();

Как правило, программист знает, какие данные должны быть в List’e. Тем не менее, стоит обратить особое внимание на Приведение типа («Cast») в строчке 3. Компилятор может лишь гарантировать, что метод next() вернёт Object, но чтобы обеспечить присвоение переменной типа Integer правильным и безопасным, требуется Cast. Cast не только создает беспорядки, но дает возможность появление ошибки «Runtime Error» из-за невнимательности программиста.

И появляется такой вопрос: «Как с этим бороться? » В частности: «Как же зарезервировать List для определенного типа данных?»

Как раз такую проблему решают Generics.

1. List myIntList = new LinkedList (); 2. myIntList.add(new Integer(0)); 3. Integer x = myIntList.iterator().next();

Обратите внимание на объявления типа для переменной myIntList. Он указывает на то, что это не просто произвольный List, а List. Мы говорим, что List является generic-интерфейсом, который принимает параметр типа — в этом случае, Integer. Кроме того, необходимо обратить внимание на то, что теперь Cast выполняется в строчке 3 автоматически.

Некоторые могут задуматься, что беспорядок в коде увеличился, но это не так. Вместо приведения к Integer в строчке 3, у нас теперь есть Integer в качестве параметра в строчке 1. Здесь существенное отличие. Теперь компилятор может проверить этот тип на корректность во время компиляции.

И когда мы говорим, что myIntList объявлен как List, это будет справедливо во всем коде и компилятор это гарантирует.

Эффект от Generics особенно проявляется в крупных проектах: он улучшает читаемость и надежность кода в целом.

Свойства

  • Строгая типизация
  • Единая реализация
  • Отсутствие информации о типе

Пример реализации Generic-класса

public interface List  < E get(int i); set(int i, E e); add(E e); Iteratoriterator(); … >

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

После того как было объявлено имя generic-типа его можно использовать как обычный тип внутри метода. И когда в коде будет объявлен, к примеру, List, то Е станет Integer для переменной list (как показано ниже).

Теперь рассмотрим чем старая реализация кода отличается от новой:

List ─ список элементов E

List list = new List(); list.add(new Integer(1)); Integer i = (Integer) list.get(0);
List list = new List(); list.add(new Integer(1)); Integer i = list.get(0);

Как видите, больше не нужно приводить Integer, так как метод get() возвращает ссылку на объект конкретного типа (в данном случае – Integer).

Несовместимость generic-типов

Это одна из самых важных вещей, которую вы должны узнать о Generics

Как говорится: «В бочке мёда есть ложка дегтя». Для того чтобы сохранить целостности и независимости друг от друга Коллекции, у Generics существует так называемая «Несовместимость generic-типов».

Пусть у нас есть тип Foo, который является подтипом Bar, и еще G - наследник Коллекции. То G не является наследником G.
List li = new ArrayList(); List lo = li;
lo.add(“hello”); // ClassCastException: String -> int Integer li = lo.get(0);

Проблемы реализации Generics

  • Решение 1 — Wildcard

Пусть мы захотели написать метод, который берет Collection и выводит на экран. И мы захотели вызвать dump для Integer.

void dump(Collection c) < for (Iteratori = c.iterator(); i.hasNext(); ) < Object o = i.next(); System.out.println(o); >>
List l; dump(l); List l; dump(l); // Ошибка 

В этом примере List не может использовать метод dump, так как он не является подтипом List.

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

Для решения этой проблемы используется Wildcard («?»). Он не имеет ограничения в использовании(то есть имеет соответствие с любым типом) и в этом его плюсы. И теперь, мы можем вызвать dump с любым типом коллекции.

void dump(Collection c) < for (Iteratori = c.iterator(); i.hasNext(); ) < Object o = i.next(); System.out.println(o); >>
  • Решение 2 – Bounded Wildcard

Пусть мы захотели написать метод, который рисует List. И у Shape есть наследник Circle. И мы хотим вызвать draw для Circle.

void draw(List c) < for (Iteratori = c.iterator(); i.hasNext(); ) < Shape s = i.next(); s.draw(); >>
List l; draw(l); List l; draw(l); // Ошибка 

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

void draw(List c) < for (Iteratori = c.iterator(); i.hasNext(); ) < Shape s = i.next(); s.draw(); >>
  • Решение 3 – Generic-Метод

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

void addAll(Object[] a, Collection c) < for (int i = 0; i < a.length; i++) < c.add(a[i]); >>
addAll(new String[10], new ArrayList()); addAll(new Object[10], new ArrayList()); addAll(new Object[10], new ArrayList()); // Ошибка addAll(new String[10], new ArrayList()); // Ошибка 

Напомним, что вы не можете просто засунуть Object в коллекции неизвестного типа. Способ решения этой проблемы является использование «Generic-Метод» Для этого перед методом нужно объявить и использовать его.

 void addAll(T[] a, Collection c) < for (int i = 0; i < a.length; i++) < c.add(a[i]); >>

Но все равно после выполнение останется ошибка в третьей строчке :

addAll(new Object[10], new ArrayList()); // Ошибка 
  • Решение 4 – Bounded type argument

Реализуем метод копирование из одной коллекции в другую

 void addAll(Collection c, Collection c2) < for (Iteratori = c.iterator(); i.hasNext(); ) < M o = i.next(); c2.add(o); >>
addAll(new AL(), new AL()); addAll(new AL(), new AL()); //Ошибка

Проблема в том что две Коллекции могут быть разных типов (несовместимость generic-типов). Для таких случаев было придуман Bounded type argument. Он нужен если метод ,который мы пишем использовал бы определенный тип данных. Для этого нужно ввести (N принимает только значения M). Также можно корректно писать . (Принимает значения нескольких переменных)

 void addAll(Collection c, Collection c2) < for (Iteratori = c.iterator(); i.hasNext(); ) < N o = i.next(); c2.add(o); >>
  • Решение 5 – Lower bounded wcard

Реализуем метод нахождение максимума в коллекции.

> T max(Collection c)

List il; Integer I = max(il); class Test implements Comparable  List tl; Test t = max(tl); // Ошибка
  • > обозначает что Т обязан реализовывать интерфейс Comparable.

Ошибка возникает из за того что Test реализует интерфейс Comparable. Решение этой проблемы — Lower bounded wcard(«Ограничение снизу»). Суть в том что мы будет реализовывать метод не только для Т, но и для его Супер-типов(Родительских типов). Например: Если мы напишем

List list;

Мы можем заполнить его List, List или List.

> T max(Collection c)

  • Решение 6 – Wildcard Capture

Реализуем метод Swap в List

void swap(List list, int i, int j) < list.set(i, list.get(j)); // Ошибка >

Проблема в том, что метод List.set() не может работать с List, так как ему не известно какой он List. Для решение этой проблемы используют «Wildcard Capture» (или «Capture helpers»). Суть заключается в том, чтобы обмануть компилятор. Напишем еще один метод с параметризованной переменной и будем его использовать внутри нашего метода.

void swap(List list, int i, int j) < swapImpl(list, i, j); > void swapImpl(List list, int i, int j)

Ограничения Generic

Также нужно запомнить простые правила для работы с Generics.

  • Невозможно создать массив параметра типа
Collection c; T[] ta; new T[10]; // Ошибка !!
  • Невозможно создать массив Generic-классов
new ArrayList>(); List[] la = new List[10]; // Ошибка !!

Преобразование типов

В Generics также можно манипулировать с информацией, хранящийся в переменных.

  • Уничтожение информации о типе
List l = new ArrayList();
  • Добавление информации о типе
List l = (List) new ArrayList(); List l1 = new ArrayList();

Примеры кода

  • Первый пример:
List ls; List li; ls.getClass() == li.getClass() // True ls instanceof List // True ls instanceof List // Запрещено
  • Второй пример:

Нахождение максимума в Коллекции Integer.

Collection c; Iterator i = c.iterator(); Integer max = (Integer) i.next(); while(i.hasNext()) < Integer next = (Integer) i.next(); if (next.compareTo(max) > 0) < max = next; >>
  • С помощью Generics
Collection c; Iterator i = c.iterator(); Integer max = i.next(); while(i.hasNext()) < Integer next = i.next(); if (next.compareTo(max) >0) < max = next; >>

Что такое дженерики в TypeScript?

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

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

Дженерики в функциях

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

interface Person < name: String >const convertStringToArray = (value: String): Array => < return [value]; >const convertNumberToArray = (value: Number): Array => < return [value]; >const convertPersonToArray = (value: Person): Array =>

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

Одна вещь, которую мы бы могли сделать, это поместить тип any , чтобы значения типа String, Number и Person можно было использовать в качестве аргументов в одной и той же функции. К сожалению, это вызывает больше проблем, чем решает (в общем, если вы планируете использовать тип any очень часто, то, возможно, лучше оставить его исключительно в JS).

Решение «проблемы повторного использования» с помощью дженериков — пример:

export interface Person < name: String; >export const convertToValueArray = (value: T): Array => < return [value]; >; const person: Person = < name: "Mahesh" >; const firstPerson = convertToValueArray(person)[0];

Функция converToValueArray получает значение выбранного типа и возвращает массив этого типа: Array . Так, например, если значение имеет тип String, то возвращаемый тип будет Array .

Давайте посмотрим, как TypeScript показывает ошибку, когда мы определяем наш дженерик-тип.

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

Выведенный тип

Давайте определим функцию, которая принимает дженерик-тип.

function convertToArray(args: T): Array

Мы можем вызвать функцию двумя способами

convertToArray("someString"); convertToArray("someString");

Как мы видим, если тип не передан в <> , то он выводится автоматически. Вывод типа делает код короче, но в сложных определениях может потребоваться явная передача типа.

Более одного дженерик-типа

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

function doStuff(name: T): T < // . some process return name; >

Вышеуказанная функция может быть вызвана следующим образом:

doStuff("someString");

Дженерик-классы

Много раз нам требовалось бы создать дженерик-класс, например, абстрактные Base (базовые) классы. В них мы можем передавать типы при создании экземпляров классов.

interface DatabaseConnector < get: Function; put: Function; >abstract class BaseLocalDatabase < tableName: String; databaseInstance: DatabaseConnector; constructor(tableName: String) < this.tableName = tableName; this.databaseInstance = getDatabase(tableName); > async insert(data: M): Promise  < await this.databaseInstance.put(data); >async get(id: Number): Promise  < return await this.databaseInstance.get(id); >abstract getFormattedData(): Array; >

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

interface ContactTable < name: String; >interface ContactModel < id: String; name: String; phoneNumber: String; profilePicture: String; createdAt: Date; updatedAt: Date; >class ContactLocalDatabase extends BaseLocalDataBase  < // overriden function getFormattedData(): ContactModel[] < // format and return data >>
  • В строке 14, при расширении дженерик-класса, в данной базе данных мы должны передать два типа. В нашем случае ContactTable и ContactModel .
  • Строка 17: База данных ContactLocalDatabase получит функции из родительского класса и должна переопределить функцию getFormattedData , поскольку она определена как абстрактная функция в родительском базовом классе.
  • Строка 17: Теперь это функция, имеющая дженерик-тип, о котором мы говорили в первом разделе.

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

let contactsLocalDatabase = new ContactLocalDatabase("Contact table"); await contactsLocalDatabase.get(21); const contactModel: ContactTable = < id: 12, name: 'Some name', . // define all other values. . >await contactsLocalDatabase.insert(contactModel); const contactArray: Array = contactsLocalDatabase.getFormattedData();
  • Строка 1: поскольку мы определили типы класса ContactLocalDatabase при использовании ключевого слова new, типы не нужно передавать в базовый класс.
  • Строки 3, 11, 13: мы можем заметить, что эти функции принадлежат абстрактному классу. Они ведут себя в соответствии с определениями дженерик-класса.

Ограничения дженериков

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

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

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

  • Строка 6: Это вполне допустимая ошибка, так как у данных дженерик-тип. Они могут быть String, Number, Float или любым другим типом, а передаваемые данные могут как учитывать длину, так и нет.

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

  • Строка 4: мы расширили тип T , чтобы он имел свойство length (длина);
  • Строка 5: исчезла ошибка, которая говорила, что свойство length не существует для типа ‘T’;
  • Строка 10: когда мы вызываем функцию с числом, она выдает ошибку, объясняя, что не выполняется условие ограничения;
  • Строки 12 и 13: когда мы передаем корректные данные, такие как String или Array, TS не выдает ошибку.

Завтра состоится открытое занятие «Обзор технологий для построения API», на котором рассмотрим несколько протоколов клиент-серверных приложений. На примере увидим сильные стороны и недостатки. Регистрация для всех желающих здесь.

  • Блог компании OTUS
  • JavaScript
  • Программирование

Generics

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

Освойте профессию «Java-разработчик»

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

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

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

Для чего нужны дженерики

С дженериками работают программисты на Java. Без этой возможности писать код, который работает только с определенным видом данных, было сложнее. Существовало два способа, и оба неоптимальные:

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

Поэтому появились дженерики: они решают эту проблему, делают написание кода проще, а защиту от ошибок надежнее.

Профессия / 14 месяцев
Java-разработчик

Освойте востребованный язык

Group 1321314345 (4)

Как работают дженерики

Чтобы вернее понять принцип работы, нужно представлять, как устроены сущности в Java. Есть классы — это как бы «чертежи» будущих сущностей, описывающие, что они делают. И есть объекты — экземпляры классов, непосредственно существующие и работающие. Класс — как схема машины, объект — как машина.

Когда разработчик создает дженерик-класс, он приписывает к нему параметр в треугольных скобках — метку. К примеру, так:

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

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

Что такое raw types

В Java есть понятие raw types. Так называют дженерик-классы, из которых удалили параметр. То есть изначально класс описали как дженерик, но при создании объекта этого класса тип ему не передали. То есть что-то вроде myClass<> — тип не указан.

Дословно это название переводится как «сырые типы». Пользоваться ими сейчас в коммерческой разработке — чаще всего плохая практика. Но в мире все еще много старого кода, который написали до появления дженериков. Если такой код еще не успели переписать, в нем может быть очень много «сырых типов». Это надо учитывать, чтобы не возникало проблем с совместимостью.

Дженерики-классы и дженерики-методы

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

  • дженерик-классы (generic classes) это классы, «схемы» объектов с параметром. При создании объекта ему передается тип, с которым он будет работать;
  • дженерик-методы (generics methods) это методы, работающие по такому же принципу. Метод — это функция внутри объекта, то, что он может делать. Методу тип передается при вызове, сразу перед аргументами. Так можно создавать более универсальные функции и применять одну и ту же логику к данным разного типа.

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

Станьте Java-разработчиком
и создавайте сложные сервисы
на востребованном языке

Что будет, если передать дженерику не тот тип

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

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

А если отправить «не тот» тип объекту без дженерика, действия с ним выполнятся с ошибкой. Но по этой ошибке не всегда очевидно, чем она вызвана. Худший вариант — код успешно запустится, но сработает неправильно: так ошибку будет найти еще сложнее.

Особенности дженериков

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

Выведение типа. Эта особенность касается объявления экземпляра класса, то есть создания объекта. Полная запись создания будет выглядеть так:

myClass objectForIntegers = new myClass();

objectForIntegers — это название объекта, оно может быть любым. То, что находится после знака «равно», — непосредственно команда «создать новый экземпляр класса».

Но полная запись очень громоздкая. Поэтому современные компиляторы Java способны на выведение типа — автоматическую его подстановку в записи после первого упоминания. То есть конструкцию myClass понадобится написать только один раз.

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

myClass objectForIntegers = new myClass<>();

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

Стирание типов. Важная деталь, которая касается работы дженериков, — они существуют только на этапе компиляции. В этом их суть: «не пропускать» данные ненужного типа в объект, а такие вещи определяет компилятор.

После компиляции код на Java превращается в байт-код. И на этом уровне никаких дженериков нет. myClass и myClass в байт-коде будут идентичны, просто с разными данными внутри.

Это называется стиранием типов. Суть в том, что внутри дженерик-класса нет информации о его параметре и после компиляции эти сведения просто исчезают. Так сделали, потому что дженерики появились в Java не сразу. Если бы информацию о параметре добавили в байт-код, это сломало бы совместимость с более старыми версиями.

О стирании типов важно помнить. Для запущенной программы в байт-коде дженериков не существует, и это может вызвать ошибки. Например, при сравнении myClass и myClass программа скажет, что они одинаковые. А иногда в объект в запущенном коде и вовсе получается передать данные другого типа.

«Дикие карты». Еще одна интересная и полезная особенность дженериков — так называемые wildcards, или «дикие карты». Это термин из спорта, означающий особое приглашение спортсмена на соревнование в обход правил. А в карточных играх так называют карты, которые можно играть вместо других, например джокера.

В основе wildcards в Java лежит такая же идея: изменить предустановленное поведение и сделать что-то в обход установленных рамок. Когда объявляется «дикая карта», в треугольных скобках вместо названия типа ставится вопросительный знак. Это означает, что сюда можно подставить любой тип.

Подставить wildcard можно не везде. Например, при создании класса это сделать не получится, а при объявлении объекта этого класса — получится. Чаще всего «дикую карту» используют при работе с переменными и с коллекциями.

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

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

Есть два вида ограничений:

  • upper bounding — ограничение сверху. За вопросительным знаком следует слово extends и название типа. В такой дженерик можно передавать названный тип и его потомков;
  • lower bounding — ограничение снизу. Ситуация наоборот: за вопросительным знаком слово super и тип, а подставлять можно элементы этого типа и его предков.

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

Java-разработчик

Java уже 20 лет в мировом топе языков программирования. На нем создают сложные финансовые сервисы, стриминги и маркетплейсы. Освойте технологии, которые нужны для backend-разработки, за 14 месяцев.

картинка (67)

Статьи по теме:

Fluent Generics in C#

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

Несмотря на то, что дженерики давно в C#, мне всё же удаётся найти новые интересные способы их применения. Например, в одной из моих предыдущих статей я написал об уловке, позволяющей добиться return type inference, что может облегчить работу с контейнерными union types.

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

После нескольких экспериментов, я нашёл способ решить проблему элегантно, используя подход схожий с паттерном проектирования fluent interface, который был применён не к объектам, а к типам. Мой подход предлагает domain-specific language, который позволяет разработчику построить нужный тип за несколько логических шагов, последовательно его «конфигурируя».

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

Fluent Interfaces

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

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

В качестве примера, рассмотрим следующий код:

var result = RunCommand( "git", "/my/repository", new Dictionary < ["GIT_AUTHOR_NAME"] = "John", ["GIT_AUTHOR_EMAIL"] = "john@email.com" >, "pull" );

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

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

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

Наш пример можно улучшить, используя паттерн fluent interface:

var result = new Command("git") .WithArguments("pull") .WithWorkingDirectory("/my/repository") .WithEnvironmentVariable("GIT_AUTHOR_NAME", "John") .WithEnvironmentVariable("GIT_AUTHOR_EMAIL", "john@email.com") .Run();

Таким образом разработчик может создать объект класса Command детально контролируя его состояние. Сначала мы указываем имя исполняемого модуля, затем используя доступные методы, свободно конфигурируем другие опции согласно надобности. Результирующее выражение не только стало заметно более читабельным, но и более гибким за счёт отказа от ограничений параметров метода в пользу паттерна fluent interface.

Определение fluent type

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

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

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

Такой тип данных мог бы быть смоделирован следующим образом:

public abstract class Endpoint : EndpointBase < public abstract Task> ExecuteAsync( TReq request, CancellationToken token = default ); >

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

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

public class SignInRequest < public string Username < get; init; >public string Password < get; init; >> public class SignInResponse < public string Token < get; init; >> public class SignInEndpoint : Endpoint < [HttpPost("auth/signin")] public override async Task> ExecuteAsync( SignInRequest request, CancellationToken token = default) < var user = await Database.GetUserAsync(request.Username); if (!user.CheckPassword(request.Password)) < return Unauthorized(); >return Ok(new SignInResponse < Token = user.GenerateToken() >); > >

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

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

Чтобы приспособить архитектуру к такого рода эндпоинтам, мы можем расширить описание типов, добавив несколько дополнительных обобщённых перегрузок:

public abstract class Endpoint : EndpointBase < public abstract Task> ExecuteAsync( TReq request, CancellationToken cancellationToken = default ); > public abstract class Endpoint : EndpointBase < public abstract TaskExecuteAsync( TReq request, CancellationToken cancellationToken = default ); > public abstract class Endpoint : EndpointBase < public abstract Task> ExecuteAsync( CancellationToken cancellationToken = default ); > public abstract class Endpoint : EndpointBase < public abstract TaskExecuteAsync( CancellationToken cancellationToken = default ); >

Поначалу может показаться, что решение проблемы найдено. Однако, код, приведённый выше, не скомпилируется. Причина тому неоднозначность между Endpoint и Endpoint , поскольку нет возможности определить означает типовой параметр запрос или ответ.

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

public abstract class Endpoint : EndpointBase < public abstract Task> ExecuteAsync( TReq request, CancellationToken cancellationToken = default ); > public abstract class EndpointWithoutResponse : EndpointBase < public abstract TaskExecuteAsync( TReq request, CancellationToken cancellationToken = default ); > public abstract class EndpointWithoutRequest : EndpointBase < public abstract Task> ExecuteAsync( CancellationToken cancellationToken = default ); > public abstract class Endpoint : EndpointBase < public abstract TaskExecuteAsync( CancellationToken cancellationToken = default ); >

Ошибка компиляции устранена, но мы получили уродливый дизайн. Из-за разного именования половины типов пользователю библиотеки придётся тратить много времени в поисках нужного. Более того, если мы представим расширение функционала библиотеки (например, добавление не асинхронных обработчиков), то станет очевидно, что такая архитектура плохо масштабируется.

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

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

public static class Endpoint < public static class WithRequest < public abstract class WithResponse< public abstract Task ExecuteAsync( TReq request, CancellationToken cancellationToken = default ); > public abstract class WithoutResponse < public abstract TaskExecuteAsync( TReq request, CancellationToken cancellationToken = default ); > > public static class WithoutRequest < public abstract class WithResponse< public abstract Task ExecuteAsync( CancellationToken cancellationToken = default ); > public abstract class WithoutResponse < public abstract TaskExecuteAsync( CancellationToken cancellationToken = default ); > > >

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

В частности, типы содержащиеся внутри дженериков имеют доступ к типовым параметрам объявленным снаружи. Это позволяет расположить WithResponse внутри WithRequest и использовать оба типа: и TReq , и TRes .

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

Теперь, если пользователь хочет реализовать эндпоинт, он может сделать это следующим образом:

public class MyEndpoint : Endpoint.WithRequest.WithResponse  < /* . */ >public class MyEndpointWithoutResponse : Endpoint.WithRequest.WithoutResponse < /* . */ >public class MyEndpointWithoutRequest : Endpoint.WithoutRequest.WithResponse  < /* . */ >public class MyEndpointWithoutNeither : Endpoint.WithoutRequest.WithoutResponse < /* . */ >

Вот как выглядит новая версия SignInEndpoint :

public class SignInEndpoint : Endpoint .WithRequest .WithResponse < [HttpPost("auth/signin")] public override async Task ExecuteAsync( SignInRequest request, CancellationToken cancellationToken = default) < // . >>

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

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

// Incomplete signature // Error: Class Endpoint is sealed public class MyEndpoint : Endpoint < /* . */ >// Incomplete signature // Error: Class Endpoint.WithRequest is sealed public class MyEndpoint : Endpoint.WithRequest  < /* . */ >// Invalid signature // Error: Class Endpoint.WithoutRequest.WithRequest does not exist public class MyEndpoint : Endpoint.WithoutRequest.WithRequest < /* . */ >

Вывод

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

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

Ещё я веду telegram канал StepOne, где оставляю небольшие заметки про разработку и мир IT.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *