Как стать автором
Обновить

Гайд на полиморфизм. Основные идеи

Уровень сложностиСредний
Время на прочтение23 мин
Количество просмотров6.4K

Полиморфизм, сколько в этом слове красивого и даже таинственного. Происходит оно от греческого πολύμορφος что означает — многообразный.  В программировании это понятие встречается часто и является обыденным для понимания большинством разработчиков. Но так ли обстоят дела на самом деле? 

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

Полиморфизм — или способность объекта выполнять специализированные действия на основе его типа.

Полиморфизм — это свойство системы использовать объекты с одинаковым интерфейсом без информации о типе и внутренней структуре объекта.

Полиморфизм — это способность объекта использовать методы производного класса, который не существует на момент создания базового.

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

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

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

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

Базовые понятия

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

Например, в таких языках как Java, C#, Kotlin и т.д., this это скрытый нулевой аргумент функции который содержит указатель на объект. Часто, называемый - method receiver.

// Неявный ресивер this
public string GetName() {
  // Мы не видим суслика а он есть
   return this._name;
}
// Явный ресивер _this
public static string GetName(User _this) {
   return _this._name;
}

...
  
User user = new User();
var u1 = user.GetName();
var u2 = User.GetName(user);

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

let a = "Hello".replace('l', "");
let b = str::replace("Hello", 'l', "");

let c = 1.add(2);
let d = i32::add(1, 2);

let user_name = "Mike".to_string();
let user = User { name: user_name };
let u1 = user.get_name();
let u2 = User::get_name(&user);

В Java есть возможность брать ссылки на методы как у объекта так и у класса. В последнем случае неявный нулевой аргумент this становится вполне явным:

Function<User, String> getNameMethod = User::getNameA;
String u1 = getNameMethod.apply(user);

С этим надеюсь разобрались. Методы - это обычный синтаксический сахар над функциями.

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

Начнем с конструкторов - функций инициализирующих объект. Какие-то языки имеют особые правила и синтаксис а в каких то, наподобие Go и Rust, конструкторами называют обычные пользовательские функции для создания структуры. В любом случае сути это не меняет.

Чтобы понять почему собаки - это киты операторы - это функции, полезно затронуть тему  способов записи выражений. Они бывают:

// префиксными
// имя расположено перед аргументами
++n
inc n
inc(n)
add n m
add(n, m)
  
// инфиксными
// имя расположено между аргументами
n + m		 
n add m 

// постфиксными
// имя расположено после аргументов
n++
n inc

Все эти способы записи просто вариации синтаксиса над вызовом функции. Не смущайтесь увидев знаки + - > и т.д. - от имен обычных функций они ничем не отличаются.

Взглянем на Kotlin, где можно самому определять как инфиксные функции так и операторы:

// Пример инфиксной функции
infix fun Int.myAdd(m: Int): Int {
   return this + m;
}
// Пример определения функции как оператора
data class Point(val x: Int, val y: Int) {
   // Определяем собственный оператор + для типа Point
   operator fun plus(other: Point): Point {
       return Point(x + other.x, y + other.y)
   }
}
fun main() {
   // Вызов как функции
   var res1 = (Int::myAdd)(1, 2)
   // Вызов как метода
   val res2 = 1.myAdd(2)
   // Вызов как инфиксной функции
   var res3 = 1 myAdd 2
  
   val pointA = Point(1, 2)
   val pointB = Point(3, 3)
   // Мы определили функцию в качестве оператора и теперь можем складывать точки
   var pointC = pointA + pointB;
}

Надеюсь теперь стало понятно что оператор - это такая же функция с особым синтаксисом.

Теперь, когда  все несколько упростилось, перейдем к главному. Попробую сформулировать простое и очевидное определение для такого понятия как полиморфизм:

Полиморфизм — это свойство программных сущностей работать сходным образом с данными разных типов.

Чуть ближе к основной теме:

Мономорфная функция — это функция способная применяться к конкретному типу данных.

Полиморфная функция — это функция способная применяется к различным типам данных.

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

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

Специальная полиморфная функция — это функция которая способна применяется к различным типам данных специальным для каждого отдельного типа образом.

Это определение описывает первую группу которая называется специальным или AD HOC полиморфизмом. Специальным он называется потому что для каждого поддерживаемого типа требуется специальная реализация. 

Универсальная полиморфная функция — это функция которая способна применяется к различным типам данных одинаковым для всех них образом.

Это определение описывает универсальный полиморфизм. Суть в том чтобы написать одну функцию и работать с любыми допустимыми для нее типами.

Более простыми словами: предположим, есть библиотека с функцией Fu, определенной для типов A и B. Есть задача также работать с новым типом C.

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

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

Перегрузка функций

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

Пример перегрузки функций на C#:

public virtual void Write(ulong value) { Write(value.ToString(FormatProvider)); }
public virtual void Write(float value) { Write(value.ToString(FormatProvider)); }
public virtual void Write(double value) { Write(value.ToString(FormatProvider)); }
public virtual void Write(decimal value) { Write(value.ToString(FormatProvider)); }
public virtual void Write(string? value) {
   if (value != null) { Write(value.ToCharArray()); }
}
public virtual void Write(object? value) {
   if (value != null) {
       if (value is IFormattable f){ Write(f.ToString(null, FormatProvider)); }
       else { Write(value.ToString()) };
   }
}

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

// Языки без параметров по умолчанию часто используют перегрузку так
public static void Send(string body) {
   Send(body, "default");
}
public static void Send(string body, string to) {
   Send(body, to, 100);
}
public static void Send(string body, int timeOut) {
   Send(body, "default", timeOut);
}
public static void Send(string body, string to, int timeOut) {...}


// В то время языки с поддержкой параметров по умолчанию в ней не нуждаются
public static void Send(string body, string to = "default", int timeOut = 100) {...}

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

Мысли вслух

Справедливости ради, перегрузка конструктора это не всегда хорошая идея. Часто правильней закрыть прямой доступ к конструктору  и создавать экземпляр типа с помощью специальных функций. Яркий пример в Java DateTime API.
В классе LocalDateимеется множество методов создания экземпляра типа:now(), of(), from(), parse()etc. Это намного удобнее чем выбирать 1 из 20 перегрузок конструктора. Как в плане поддержки так и использования.

Где перегрузка действительно показывает свою пользу и потенциал - это в работе с операторами. Взгляните на Java, где от перегрузки операторов намеренно отказались:

Vector3 complex = a.add(b).subtract(new Vector3(1, 1, 1)).multiply(0.5);

и как тоже самое будет выглядеть на C#

Vector3 complex = (a + b - new Vector3(1, 1, 1)) * 0.5;

ну и любимое

BigDecimal result = (amount1.add(amount2)).multiply(BigDecimal.ONE.add(rate)).divide(new BigDecimal("2"), 10, RoundingMode.HALF_UP).subtract(correction);

против

decimal result = ((amount1 + amount2) * (1 + rate) / 2) - correction;

Пример на Kotlin. Операторы аналогичные им обычные функции:

dateB > dateA                     // dateB.isAfter(dateA)
dateA == dateB                    // dateA.isEqual(dateB)
dateA < dateB                     // dateA.isBefore(dateB)
dateC in dateA..dateB             // dateA.rangeTo(dateB).contains(dateC)


listOf(1, 2, 3) + listOf(4, 5, 6) // listOf(1, 2, 3).plus(listOf(4, 5, 6))
listOf(1, 2, 3) + 4               // listOf(1, 2, 3).plus(4)
1 in listOf(1, 2, 3)              // listOf(1, 2, 3).contains(1)


map["One"]                        // map.get("One")
"One" in map                      // map.containsKey("One")

Пример наглядно демонстрирует как правильное использование операторов делают код минималистичнее и проще для восприятия.

Приведение типов

Начнем с простого и безобидного:

public static long Calculate(long n) {
   return n + 100 * 2;
}

Функция вроде бы принимает тип long но на деле многие языки могут производить неявные преобразования:

byte b = 100;
short s = 100;
int i = 100;
long r1 = Calculate(b);
long r2 = Calculate(s);
long r3 = Calculate(i);

Позволяющие расширить круг используемых типов. Работает это только в сторону приведения к типу большего размера.

Немного боли

Да да, привет Rust. Привет as usize.

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

public static Connection Connect(Address address) {...}

Для того чтобы получить соединение мы сделаем следующее:

var connection = Connect(new Address("192.168.1.1", 8453));

Адрес в формате строки “192.168.1.1:8453” придется сначала парсить и затем создавать объект Address. Можно конечно написать перегрузку но делать это придется для каждой функции принимающей Address. Альтернативный вариант - использовать неявное приведение типа:

public struct Address {
  
   public string Host { get; init; }
   public int Port { get; init; }
  
   public Address(string host, int port) {
       Host = host;
       Port = port;
   }
   public static implicit operator Address(string address) {
       Validator.Validate(address);
       var pair = address.Split(":");
       return new Address(pair[0], Convert.ToInt32(pair[1]));
   }
}

Ключевое слово implicit определяет оператор неявного преобразования. Тип string, переданный в функцию где требуется Address, будет неявно преобразован к нему с помощью данной функции.

var connection1 = Connect(new Address("192.168.1.1", 8453));
var connection2 = Connect("192.168.1.1:8453");

Любая функция требующая Address будет полиморфна по этому параметру так как сможет принимать любой тип для которого Address реализовал неявное преобразование.

Algebraic data types

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

Тип данных представляет из себя множество допустимых значений. Например логический тип это два возможных варианта или true или false представляющих множество
{ true | false }. Если спрятать такой тип за nullable ссылкой то можно получить уже 3 возможных значения { true | false | null }. Целочисленный 32 битный тип это уже 4 294 967 296 допустимых значений { 2,147,483,648 |…| -1 | 0 | 1 | .. |  2,147,483,647 }. Языки программирования не часто дают возможность создавать собственные типы путем перечисления множества допустимых вариантов. Чаще всего можно встретить перечисления констант:

public enum DayOfWeek {
 Sunday,
 Monday,
 Tuesday,
 Wednesday,
 Thursday,
 Friday,
 Saturday,
}

Поддержка возможности создавать новый тип и определять множество допустимых для него значений на основе других типов это и есть поддержка алгебраических типов данных. Примеры таких языков: Haskell, Rust, F# и TypeScript. Что характерно, возможность принимать тип определенный множеством других типов делают функцию полиморфной. Начнем с простого, создадим в Rust тип определяющий дни недели как в примере выше:

enum DayOfWeek {
    Sunday,
    Monday,
    Tuesday,
    Wednesday,
    Thursday,
    Friday,
    Saturday,
}

Отличий немного. Рассмотрим что-нибудь поинтереснее. Например представление типа Option в стандартной библиотеке Rust:

pub enum Option<T> {
    None,
    Some(T),
}

Все предельно просто. У типа Option два допустимых варианта: либо значения просто нет, либо оно есть и хранится внутри Some.

fn show(n: &Option<i32>) {
    match n {
        Some(value) => println!("Value is {value}"),
        None => println!("Value is not present"),
    }
}

Данная функция предельно проста - она распаковывает n и печатает значение в случаи наличия или пишет о том что значение не представлено.

Может показаться что Optional в Java - это тоже самое, но на деле это скорей имитация такого поведения и это станет понятно в следующем примере:

Определение типов
struct Article {
    id: u32,
    text: String,
    author_id: u32,
}
enum SearchCriteria {
    ById(u32),
    ByTextPart(String),
}


enum SearchType {
    FindOne,
    FindMany { page: u32, size: u32 },
}


enum SearchResult {
    FoundOne(Article),
    FoundMany(Vec<Article>),
    NotFound,
    Error(String),
}


enum DeletingResult {
    Success,
    NotFound,
    Error(String),
}
enum ArticleRequest {
    Create {
        text: String,
    },
    Update {
        article_id: u32,
        new_text: String,
    },
    Delete {
        article_id: u32,
    },
    Find {
        search_type: SearchType,
        criteria: SearchCriteria,
    },
    SyncCatalog,
}

enum ArticleResponse {
    CreatedInfo(Article),
    UnableToCreate(String),
    UnableToUpdate(String),
    Deleted(DeletingResult),
    Searched(SearchResult),
    Synced,
    UnexpectedError(String),
}

У нас есть тип ArticleRequest определяющий все варианты выполнения действий со статьями и есть тип ArticleResponse для всех возможных вариантов результатов обработки запроса. Настало время для полиморфной функции:

fn perform(operation: ArticleRequest) -> ArticleResponse {
    match operation {
        ArticleRequest::Create { text } => create(text),
        ArticleRequest::Update {
            article_id,
            new_text,
        } => update(article_id, new_text),
        ArticleRequest::Delete { article_id } => delete(article_id),
        ArticleRequest::SyncCatalog => sync(),
        ArticleRequest::Find {
            search_type,
            criteria,
        } => find(search_type, criteria),
    }
}

Функция perform способна принять любой из вариантов типа ArticleRequest и с помощью сопоставления с образцом выбирать подходящую для обработки логику.  

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

public string HandleResponse(IResponse response) {
   if (response is Successful successful) {
       return successful.Body;
   }
   if (response is Error error) {
       throw new Exception(error.Message);
   }
   throw new Exception();
}

Полиморфизм подтипов

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

Идея наследования типов проста:

если тип B наследуется от типа A то публичные методы A переходят по наследству типу B и следовательно любой B можно подставить вместо любого A так как B это частный случай A.

Приведем пример:

Смеха роста офисного планктона
Смеха роста офисного планктона

На схеме, Object предок всех типов, User наследует методы Object и добавляет свои. Так происходит на каждой ступени наследования. Многие думают что это и есть полиморфизм в представлении ООП языков. Нет, это все еще наследование.

Для реализации полиморфизма рассмотрим еще пару вещей. Первое, тип, в программировании, обычно определяет такие свойства данных как размер и способ работы. В нашем случае упростим до - какие методы доступны для объекта. Второе, в системах с наследованием типов, доступны операции приведения к родителю (upcasting) и приведение к дочернему типу (downcasting).

На изображении можно наблюдать объект с логином "ivanov" - это администратор системы. Можно сделать upcasting типа Admin до любого из его родителей. Чем выше будет тип родителя тем абстрактнее будет api нашего объекта но тем меньшим количеством методов он сможет оперировать. Например если привести объект который имел тип Admin к его родителю - User то он потеряет как возможность назначать разрешения, доступную типу Admin так и возможность работать с документами доступную типу Employee.

Другой тип приведения downcasting наоборот предполагает усиление возможностей типа за счет его уточнения. Например объект "petrov", будучи изначально Employee, приведенный к родителю User, можно привести обратно к Employee или к любому из промежуточных типов. Стоит обратить внимание что downcasting это небезопасная операция - если попытаться привести "petrov" к типу Admin, которому тот не соответствует, то произойдет ошибка.

Давайте вернемся к полиморфизму. В примере две невероятно полезные мономорфные функции, работающие с конкретными типами:

public static void PrintLogin(Visitor visitor) {
   Console.WriteLine(visitor.GetLogin());
}
public static void PrintLogin(Admin admin) {
   Console.WriteLine(admin.GetLogin());
}

Выделим два важных факта: любой потомок умеет то что умеет родитель, любой объект можно как привести к родителю так и уточнить до изначального типа. Компилятор и среда выполнения может автоматически произвести upcasting  любого дочернего объекта к необходимому родительскому типу и удовлетворить требование по типу функции. В примере выше, getLogin - это метод типа User, а это значит что если передать любого потомка User, то аргумент будет приведен к User автоматически. Попробуем:

public static void PrintLogin(User user) {
   Console.WriteLine(user.GetLogin());
}

Данная функция уже полиморфна и работает универсально с любыми типами-наследниками: Employee, Admin и Visitor.

PrintLogin(new User());
PrintLogin(new Employee());
PrintLogin(new Admin());
PrintLogin(new Visitor());

Таким незамысловатым образом устроен полиморфизм подтипов суть которого установить абстрактную границу возможностей функции и определить входные параметры по этой границе.

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

Утиная типизация

Если это выглядит как утка, плавает как утка и крякает как утка, то это, вероятно, и есть утка.

Смысл данного вида типизации в следующем: если тип содержит подходящие функции, этого достаточно чтобы им воспользоваться . Если функция Fu требует тип B но в типе A имеются функции идентичные по наименованию, параметрам и возвращаемому значению, то в функцию Fu можно передать тип A.  В противовес этому, в классической номинативной типизации если Fu принимает B то значит должен быть передан именно B и никак иначе.  

Возможно удивлю, но в C# есть элементы утиной типизации. К примеру, для того чтобы класс можно было  использовать в foreach, необходимо для него реализовать интерфейс IEnumerable. Это знают многие. Но не все знают что это делать необязательно. Напишем класс который перебирает ходы на шахматной доске:

class ChessBoard {
  
   private readonly char[] _files = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
   private readonly int[] _ranks = [1, 2, 3, 4, 5, 6, 7, 8];
  
   public IEnumerator<string> GetEnumerator() {
       foreach (var rank in _ranks) {
           foreach (var file in _files) {
               yield return $"{file}{rank}";
           }
       }
   }
}

Обратите внимание что класс ChessBoard не реализует IEnumerable. В C# достаточно чтобы метод GetEnumerator просто присутствовал в классе. Вот вам и утиная типизация.

Любопытно также посмотреть как это реализовано в Go:

type Logger interface {
	Log(message string)
}

type ConsoleLogger struct{}

func (c ConsoleLogger) Log(message string) {
	fmt.Println("[Console]", message)
}

type FileLogger struct {
	File *os.File
}

func (f FileLogger) Log(message string) {
	timestamp := time.Now().Format("2006-01-02 15:04:05")
	fmt.Fprintf(f.File, "[%s] %s\n", timestamp, message)
}

func Process(logger Logger) {
	logger.Log("Программа запущена")
	logger.Log("Процесс выполняется")
	logger.Log("Готово!")
}

Все ну очень похоже не полиморфизм подтипов кроме одного но - отсутствует явная привязка реализации к конкретному типу. Здесь работает принцип утиной типизации, в функцию Process можно передать все что угодно у чего есть функция Log(message string).

=\

Вроде бы Go хомяк а ведет себя как утка.

Параметрический полиморфизм

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

public static <T> List<T> toList(T value) {
   return List.of(value);
}

Вернемся к названию данного вида полиморфизма - параметрический.
Это ключ к пониманию главной концепции. Для обычной функции характерно наличие входящих параметров, позволяющих передавать в нее различные значения. Параметрический полиморфизм дает возможность передавать в функцию типы. Отсюда и происходит название.
Часто одна из причин непонимания неудачный синтаксис, как в Java в примере выше. Лучше все таки вернемся к C# :

public static IList<T> ToList<T>(T value) {
   return new List<T>() { value };
}

Взгляните на эту часть ToList<T>(T value). В конце привычный блок параметров 
(T value) обрамленный круглыми скобками, определяющий что функция требует передать значение типа T. А перед ними блок параметров типов <T> обрамленный угловыми скобками определяющий что функция требует передать некий тип. 

имя функции<блок параметров типов>(блок параметров значений)

Если посмотреть на вызов этой функции то все встанет на свои места:

IList<int> list = ToList<int>(1);
:\

В отличии от синтаксиса Java, который местами призван свести разработчика с ума:

public static <T> List<T> toList(T value) {
   return List.of(value);
}

...
  
List<Integer> list = SomeClass.<Integer>toList(1);

Аргумент 1 передан в блок параметров значений и аргумент-тип int в блок параметров типов.  Параметр типа T это заполняемый шаблон, передаваемый в функцию извне, условно преобразуя ее к подобному виду :

public static IList<int> ToList<int>(int value) {
   return new List<int>() { value };
}

Один из типов был вынесен как шаблон что позволило передавать различные его вариации извне.

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

Чтобы лучше понять идею возьмем две функции которые принимают только параметры типов:

public static IList<T> EmptyList<T>() {
   return new List<T>();
}

public static void PrintType<T>() {
   Console.WriteLine(typeof(T));
}

А вот их вызовы:

IList<int> intList = EmptyList<int>();
IList<string> strList = EmptyList<string>();

// System.Int32
PrintType<int>();
// System.String
PrintType<string>();

Вы можете условно представить что в момент вызова произошло следующее:

public static IList<int> EmptyList<int>() {
   return new List<int>();
}
public static IList<string> EmptyList<string>() {
   return new List<string>();
}
public static void PrintType<int>() {
   Console.WriteLine(typeof(int));
}
public static void PrintType<string>() {
   Console.WriteLine(typeof(string));
}

Представление и возможности параметров типов сильно зависят от конкретной реализации в языке программирования. Например в Java нельзя реализовать метод PrintType подобным образом. Но это тема для другой статьи.

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

Параметр типа и аргумент типа

 <T> ← <int>

Параметр значения использующий параметр типа и аргумент передаваемый в него

 (T value) ←(1)

Возвращаемое значение

IList<T> ← IList<int>

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

Примеры:

// Передан int логично что T == int и IList<T> == IList<int>
var list1 = ToList(1);

// Нельзя вывести тип так как нет входных параметров, нужно указывать явно
var emptyList = EmptyList<int>();

// К сожалению компилятор C# не умеет выводить параметр типа из возвращаемого значения
// IList<int> emptyList2 = EmptyList();

В примере выше, компилятор C# не смог вывести параметр типа по возвращаемому значению. Такая же история есть в других языках, например Java. В более современных языках типа TypeScript, Koltin, Rust таких проблем нет:

// TS
const emptyList1 = emptyList<number>();
const emptyList2: number[] = emptyList();
// Kotlin
val emptyList1 = emptyList<Int>()
val emptyList2: List<Int> = emptyList()
// Rust
let emptyList1 = emptyList::<i32>();
let emptyList2: Vec<i32> = emptyList();

Перейдем к использованию. Функция получила T в качестве параметра типа, но как его использовать? В прошлых примерах T передавался  в другие контейнеры либо функции. Что полезного можно сделать с таким типом?  T - это буквально все что угодно. В  C# "все что угодно" определяется типом Object и содержит небогатый арсенал методов.

public static void Fu<T>(T valueA) {
   Console.WriteLine(valueA?.Equals(null));
   Console.WriteLine(valueA?.GetHashCode());
   Console.WriteLine(valueA?.ToString());
   Console.WriteLine(valueA?.GetType());
}

К счастью идея параметрического полиморфизма идет в комплекте с механизмом ограничений через контракты. Можно установить ограничение для параметра типа снизив его абстрактность но увеличив возможности. Реализация контрактов зависит от языка программирования. В C# для реализации контрактов используется система наследования типов.

public interface IComparable<in T> {
 int CompareTo(T? other);
}
...
// Используем интерфейс IComparable в качестве ограничения
public static void PrintCompared<T>(T valueA, T valueB) where T : IComparable<T> {
   switch (valueA.CompareTo(valueB)) {
       case 0:
           Console.WriteLine($"{valueA} equals {valueB}");
           break;
       case > 0:
           Console.WriteLine($"{valueA} more than {valueB}");
           break;
       default:
           Console.WriteLine($"{valueA} less than {valueB}");
           break;
   }
}

Ограничение представленное как where T : IComparable<T> позволяет передавать в качестве параметра только типы реализующие интерфейс IComparable.Теперь T обязан реализовывать метод CompareTo, и мы можем использовать все возможные методы IComparable внутри PrintCompared.

// 1 less than 2
PrintCompared(1, 2);
// 1 less than 2
PrintCompared(1.0, 2.0);
// 1 less than 2
PrintCompared("1", "2");

Функция PrintCompared полиморфна, позволяет работать с любым типом который можно сравнить.

Можно использовать несколько ограничений одновременно. Например ограничить тип только числами:

public static void PrintCompared<T>(T valueA, T valueB) where T : IComparable<T>, INumber<T> {
   switch (valueA.CompareTo(valueB)) {
       case 0:
           Console.WriteLine($"{valueA} equals {valueB}");
           break;
       case > 0:
           Console.WriteLine($"{valueA} more than {valueB}");
           break;
       default:
           Console.WriteLine($"{valueA} less than {valueB}");
           break;
   }
}

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

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

interface Appendable<T> where T : INumber<T> {
   void Append(T value);
   T Total();
}

class IntAppender : Appendable<int> {
   private int _acc;
   public void Append(int value) {
       _acc += value;
   }
   public int Total() {
       return _acc;
   }
}

class ListAppender<T> : Appendable<T> where T : INumber<T> {
   private readonly List<T> _list = [];
   public void Append(T value) {
       _list.Add(value);
   }
   public T Total() {
       return _list.Aggregate((a, b) => a + b);
   }
}

В реализации IntAppender представлено место использования параметров типов. Здесь уточняется тип для Appendable. В коде IntAppender уже используется конкретный тип int.

В реализации ListAppender видно как объявление нового параметра типа для класса ListAppender так и использование его для уточнения Appendable. Благодаря чему сохраняется возможность работать с абстракцией.

Ну и наконец, использование обоих классов. Здесь ничего особенного, ListAppender может быть уточнен любым числовым типов в то время как в IntAppender зашит тип int.

Appendable<int> intAppender = new IntAppender();
Appendable<double> listAppender = new ListAppender<double>();

Напоследок хотелось бы добавить про интересные возможности параметрического полиморфизма. В разных языках программирования различный подход к его реализации. Где-то, как в Java, типы являются просто ограничителями для компилятора а где то они являются полноценными параметрами: C#, Rust, местами Kotlin. Для примера, возьмем  такой код на Rust:

let set = vec![1, 2, 3]
    .iter()
    .map(|n| n + 1)
    .collect::<HashSet<i32>>();

Если мы возьмем похожий код на Java то это будет выглядеть так:

var set = List.of(1, 2, 3)
       .stream()
       .map(n -> n + 1)
       .collect(Collectors.toSet());

Код почти идентичный но все же бросается в глаза то что в Java нужно явно вызывать метод Collectors.toSet() чтобы указать в какую структуру данных преобразовать итератор, в Rust же достаточно прописать тип явно и он все сделает сам. Это возможно благодаря продвинутой системе типов.

    fn collect<B: FromIterator<Self::Item>>(self) -> B where Self: Sized {
        FromIterator::from_iter(self)
    }

Целевой тип B должен реализовывать контракт по которому он может быть преобразован из итератора. Контракт и его реализация для HashSet. Все просто:

pub trait FromIterator<A>: Sized {
    fn from_iter<T: IntoIterator<Item = A>>(iter: T) -> Self;
}
...

fn from_iter<I: IntoIterator<Item = T>>(iter: I) -> Self {
    let mut set = Self::with_hasher_in(Default::default(), Default::default());
    set.extend(iter);
    set
}

Интересное можно найти и в C#:

class IntContainer {
   public static int Value { get; }
}
// Все знают что с типом могут быть ассоциированы статические переменные. 
// Но что будет если эти переменные параметризировать?
class Container<T> {
   public static T Value { get; set; }
}

Если бы это был код на Java то компилятор бы просто сдался:

non-static type variable T cannot be referenced from a static context

Компилятор не понимает как представить класс Container во время выполнения если он может быть параметризован любым T. Вместо T могут быть подставлены разные типы а под капотом один и тот же класс. Но C# не так прост, все дело в том что в отличии от Java, где используется боксинг для реализации параметрического полиморфизма, C# использует специализацию т.е. метод при котором для каждого параметризованного типа создается отдельный класс. Иными словами, Container<int> - в рантайме это ContainerInt а Container<float> - это ContainerFloat.

Интересным эффектом от такого решения стало то что статические переменные каждого параметризованного типа независимы друг от друга. Это позволяет использовать данную особенность как словарь где ключами являются типы. Приятным бонусом мы получаем константное время доступа т.к. по сути просто обращается к определенному классу через вызов метода.

Container<int>.Value = 10;
Container<float>.Value = 20.0f;
Container<string>.Value = "30";
Container<A>.Value = new A { Value = 40 };
Container<B>.Value = new B { Value = 50 };
Container<C>.Value = new C { Value = true };

Console.WriteLine(Container<int>.Value);     // 10
Console.WriteLine(Container<float>.Value);   // 20
Console.WriteLine(Container<string>.Value);  // 30
Console.WriteLine(Container<A>.Value);       // A { Value = 40 }
Console.WriteLine(Container<B>.Value);       // B { Value = 50 }
Console.WriteLine(Container<C>.Value);       // C { Value = True }

Для того чтобы изучить как можно использовать всю мощь параметров типов в C# очень советую ознакомиться с ECS библиотекой StaticEcs. Возможно даже кому-то она пригодится для написания игры.

Вишенка на торте

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

Код примера
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>


#define PORT 8080
#define BUFFER_SIZE 1024


int main(void) {

    int server_fd = socket(AF_INET, SOCK_STREAM, 0);

    if (server_fd < 0) {
        perror("Socket creating failure");
        exit(EXIT_FAILURE);
    }

    int opt = 1;

    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
        perror("Socket options setting failure");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);

    if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        perror("Binding error");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    if (listen(server_fd, 5) < 0) {
        perror("Listening error");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    char buffer[BUFFER_SIZE];

    const char *response =
        "HTTP/1.1 200 OK\r\n"
        "Content-Type: text/html; charset=UTF-8\r\n"
        "Content-Length: 48\r\n"
        "Connection: close\r\n"
        "\r\n"
        "<html><body><h1>Hello, World!</h1></body></html>";

    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);
    int client_fd;

    for (;;) {
        client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
        if (client_fd < 0) {
            perror("Accepting error");
            continue;
        }

        printf("Connection %s:%d\n", inet_ntoa(client_addr.sin_addr), client_addr.sin_port);

        memset(buffer, 0, BUFFER_SIZE);
       
        ssize_t bytes_read = read(client_fd, buffer, BUFFER_SIZE - 1);

        if (bytes_read < 0) {
            perror("Read error");
            close(client_fd);
            continue;
        }
      
        buffer[bytes_read] = '\0';
        printf("Request receiving:\n%s\n", buffer);
      
        ssize_t bytes_writen = write(client_fd, response, strlen(response));
        if (bytes_writen < 0) {
            perror("Read error");
            close(client_fd);
            continue;
        }

        close(client_fd);
    }

    close(server_fd);

    return 0;
}

Из примера выше представляют интерес две функции:

// связывает локальный адрес с сокетом
bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr))
...
// ожидает соединение и открывает новый сокет для работы с ним
int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);  

Взглянем на код:

(struct sockaddr *)&server_addr
(struct sockaddr *)&client_addr

Здесь можно увидеть, как указатель на тип sockaddr_in приводится к типу sockaddr.  Неужели полиморфизм? Да, он самый. Есть обобщенная структура, содержащая минимальную информацию о типе адреса.

/* Structure describing a generic socket address.  */
struct sockaddr
  {
    __SOCKADDR_COMMON (sa_);  /* Common data: address family and length.  */
    char sa_data[14];   /* Address data.  */
  };

А есть структуры реализующие конкретные форматы адреса. Использованные выше sockaddr_in или sockaddr_in6 для Ipv6:

/* Structure describing an Internet socket address.  */
struct sockaddr_in
  {
    __SOCKADDR_COMMON (sin_);
    in_port_t sin_port;     /* Port number.  */
    struct in_addr sin_addr;    /* Internet address.  */

    /* Pad to size of `struct sockaddr'.  */
    unsigned char sin_zero[sizeof (struct sockaddr)
         - __SOCKADDR_COMMON_SIZE
         - sizeof (in_port_t)
         - sizeof (struct in_addr)];
  };
struct sockaddr_in6
  {
    __SOCKADDR_COMMON (sin6_);
    in_port_t sin6_port;  /* Transport layer port # */
    uint32_t sin6_flowinfo; /* IPv6 flow information */
    struct in6_addr sin6_addr;  /* IPv6 address */
    uint32_t sin6_scope_id; /* IPv6 scope-id */
  };

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

Таким образом функции bind и accept могут быть полиморфными и принимать различные типы адресов. И это все на C без каких либо проблем.

Заключение

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

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

Теги:
Хабы:
+5
Комментарии25

Публикации

Работа

Ближайшие события