Полиморфизм, сколько в этом слове красивого и даже таинственного. Происходит оно от греческого πολύμορφος что означает — многообразный. В программировании это понятие встречается часто и является обыденным для понимания большинством разработчиков. Но так ли обстоят дела на самом деле?
Чаще других этот термин встречается в связанных с ООП темах как часть набивший оскомину триады вместе с инкапсуляцией и наследованием, ну и конечно же какое классическое собеседование без таких вопросов. Вроде бы все должны знать что это и однажды, чтобы проверить, я решил немного погуглить:
Полиморфизм — или способность объекта выполнять специализированные действия на основе его типа.
Полиморфизм — это свойство системы использовать объекты с одинаковым интерфейсом без информации о типе и внутренней структуре объекта.
Полиморфизм — это способность объекта использовать методы производного класса, который не существует на момент создания базового.
Полиморфизм — это свойство, которое позволяет одно и тоже имя использовать для решения нескольких технически разных задач.
В общем смысле, концепцией полиморфизма является идея “один интерфейс, множество методов”. Это означает, что можно создать общий интерфейс для группы близких по смыслу действий.
Полиморфизм — это возможность применения одноименных методов с одинаковыми или различными наборами параметров в одном классе или в группе классов, связанных отношением наследования.
Удивительно, но вместо четкой формулировки встречаем набор разрозненных определений больше похожих на поток сознания чем на то что авторы сами понимают о чем пишут. Оказалось что несмотря на популярность понятия, в действительности есть проблемы с его пониманием. Это побудило взять дело в свои руки и разобрать данный вопрос.
Базовые понятия
Для лучшего восприятия, начнем с определений и упрощений. Как бы не выглядели языки программирования, обычно они используется схожий подход - есть данные и есть функции которые к данным применяются. Взять к примеру ООП. Некоторые предполагают что ООП отличается от других парадигм, ведь там есть объекты а у объектов есть методы. Методы - это особые функции привязанные к объекту. На деле же ничего особого в них нет и это не более чем удобный синтаксический сахар над обычными функциями.
Например, в таких языках как 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
реализовал неявное преобразование.
Disjoint union и 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 без каких либо проблем.
Заключение
Мы рассмотрели что в принципе из себя представляет полиморфизм в программировании, какие основные его виды бывают и разобрали примеры его использования. Как вы могли заметить, полиморфизм - это не про ООП, это история о том как различными способами дать возможность функциям работать гибко с данными разных типов.
Статья получилась довольно большой, поэтому в нее не вошли вопросы реализации того или иного варианта полиморфизма и также некоторые нюансы вроде вариативности. Об этом поговорим в следующих циклах.
Комментарии (12)
Dhwtj
17.05.2025 07:41Статью чертовски тяжело читать!
КДПВ Насколько, что проще спросить LLM
Полиморфизм – это одна из фундаментальных концепций в программировании, но её понимание действительно может варьироваться.
Что такое полиморфизм? (Общая идея)
Слово "полиморфизм" происходит от греческих слов "поли" (много) и "морфе" (форма). В программировании это означает способность объекта, функции или операции вести себя по-разному в зависимости от контекста, в котором они используются, чаще всего — в зависимости от типов данных, с которыми они работают.
По сути, это принцип "один интерфейс — множество реализаций". Вы обращаетесь к чему-то по одному имени (или через один и тот же символ операции), а система сама определяет, какое конкретное действие нужно выполнить.
Почему нет единого определения?
-
Разные парадигмы:
ООП (Объектно-ориентированное программирование): Здесь полиморфизм чаще всего ассоциируется с наследованием и виртуальными методами (полиморфизм подтипов). Объект базового класса может ссылаться на объект производного класса, и при вызове метода будет выполнен метод производного класса.
ФП (Функциональное программирование): Полиморфизм проявляется через параметрический полиморфизм (обобщенные функции, работающие с любыми типами) и ad-hoc полиморфизм (перегрузка функций, классы типов в Haskell).
Процедурное/Императивное программирование: Даже здесь есть элементы полиморфизма, например, перегрузка функций и операторов (ad-hoc).
Уровень абстракции: Некоторые определения очень общие ("много форм"), другие — более конкретные и привязаны к механизмам реализации (например, "динамическое связывание").
Историческое развитие: Концепция развивалась, и разные языки реализовывали её по-своему, что приводило к разным акцентам.
Из-за этих различий в контекстах и реализациях сложно дать одно универсальное определение, которое бы одинаково точно описывало все его проявления во всех парадигмах. Однако общая идея "один интерфейс, много реализаций" или "разное поведение для разных типов" остается центральной.
Виды полиморфизма (независимо от ООП/ФП):
Давайте рассмотрим основные виды, стараясь абстрагироваться от конкретных парадигм.
-
Ad-hoc полиморфизм (Специальный / Частный полиморфизм / Ad-hoc Polymorphism)
Суть: Позволяет функции или оператору вести себя по-разному для разных типов аргументов. Реализации для каждого типа определяются "специально" (ad-hoc).
-
Проявления:
Перегрузка функций (Function Overloading): Несколько функций с одинаковым именем, но разным набором (типами и/или количеством) параметров. Компилятор или интерпретатор выбирает нужную версию на основе аргументов вызова.
Перегрузка операторов (Operator Overloading): Операторы (как
+
,-
,*
) могут выполнять разные действия в зависимости от типов операндов (например,+
для чисел — сложение, для строк — конкатенация).
-
Псевдокод (потоковый, концептуальный):
// Перегрузка функций FUNCTION calculate_area(radius: Число) -> Число // Логика для круга ВЫВОД "Вычисляю площадь круга" ВОЗВРАТ 3.14159 * radius * radius END FUNCTION FUNCTION calculate_area(width: Число, height: Число) -> Число // Логика для прямоугольника ВЫВОД "Вычисляю площадь прямоугольника" ВОЗВРАТ width * height END FUNCTION // Использование площадь_круга = calculate_area(5.0) // Вызовется первая функция площадь_прямоугольника = calculate_area(4.0, 6.0) // Вызовется вторая функция // Перегрузка оператора (концептуально) // Язык должен поддерживать определение операторов для пользовательских типов // Пусть у нас есть тип 'Вектор2D' DEFINE TYPE Вектор2D СОСТОИТ_ИЗ (x: Число, y: Число) OPERATOR + (v1: Вектор2D, v2: Вектор2D) -> Вектор2D новый_x = v1.x + v2.x новый_y = v1.y + v2.y ВОЗВРАТ СОЗДАТЬ Вектор2D С (x = новый_x, y = новый_y) END OPERATOR // Использование вектор_a = СОЗДАТЬ Вектор2D С (x=1, y=2) вектор_b = СОЗДАТЬ Вектор2D С (x=3, y=4) сумма_векторов = вектор_a + вектор_b // Сработает перегруженный '+' число_a = 5 число_b = 10 сумма_чисел = число_a + число_b // Стандартное сложение чисел
-
Параметрический полиморфизм (Parametric Polymorphism / Generics)
Суть: Позволяет писать функции или определять структуры данных, которые могут работать с любым типом данных, не зная его заранее. Тип указывается как параметр. Код пишется один раз и работает для многих типов единообразно.
Проявления: Обобщенные типы (Generics) в Java, C#, TypeScript; шаблоны (templates) в C++; обобщенные функции в Haskell, Python (через утиную типизацию).
-
Псевдокод (потоковый, концептуальный):
// Обобщенная функция для получения первого элемента списка // 'ТипЭлемента' - это параметр типа FUNCTION получить_первый<ТипЭлемента>(список: Список<ТипЭлемента>) -> ТипЭлемента ИЛИ Null ЕСЛИ список НЕ ПУСТОЙ ТО ВОЗВРАТ список[0] // Индексация списка ИНАЧЕ ВОЗВРАТ Null КОНЕЦ ЕСЛИ END FUNCTION // Использование список_чисел = [10, 20, 30] первое_число = получить_первый<Число>(список_чисел) // ТипЭлемента = Число ВЫВОД первое_число // Выведет 10 список_строк = ["альфа", "бета", "гамма"] первая_строка = получить_первый<Строка>(список_строк) // ТипЭлемента = Строка ВЫВОД первая_строка // Выведет "альфа" // Обобщенная структура данных (концептуально) DEFINE TYPE Пара<Ключ, Значение> СОСТОИТ_ИЗ ( ключ_элемента: Ключ, значение_элемента: Значение ) // Использование пара_число_строка = СОЗДАТЬ Пара<Число, Строка> С (ключ_элемента = 1, значение_элемента = "один") пара_строка_булево = СОЗДАТЬ Пара<Строка, Булево> С (ключ_элемента = "активно", значение_элемента = ИСТИНА)
-
Полиморфизм подтипов (Subtype Polymorphism / Inclusion Polymorphism)
Суть: Позволяет использовать объекты производных типов там, где ожидаются объекты базового типа. При вызове операции (метода) будет выполнена та реализация, которая соответствует фактическому типу объекта, а не типу ссылки/переменной. Это часто называют "динамическим связыванием" или "поздним связыванием".
Проявления: Классический пример — виртуальные методы в ООП (C++, Java, C#). В языках с утиной типизацией (Python, Ruby) это достигается неявно: если объект "крякает как утка", он считается уткой.
-
Псевдокод (потоковый, концептуальный, стараясь избегать чисто ООП-терминов, насколько возможно):
Представим, что у нас есть разные "сущности", которые могут "издавать звук".// Описание "контракта" или общего поведения // В ООП это был бы абстрактный класс или интерфейс // Здесь мы опишем его концептуально // Определяем типы сущностей DEFINE ENUM СущностьТип { СОБАКА, КОШКА, УТКА } // Структуры для данных каждой сущности DEFINE TYPE ДанныеСобаки СОСТОИТ_ИЗ (имя: Строка, порода: Строка) DEFINE TYPE ДанныеКошки СОСТОИТ_ИЗ (имя: Строка, цвет_шерсти: Строка) DEFINE TYPE ДанныеУтки СОСТОИТ_ИЗ (имя: Строка, умеет_летать: Булево) // Обобщенная структура сущности DEFINE TYPE Сущность СОСТОИТ_ИЗ ( тип: СущностьТип, данные: УКАЗАТЕЛЬ НА (ДанныеСобаки ИЛИ ДанныеКошки ИЛИ ДанныеУтки) // или объединение (union) ) // Функции, реализующие "издание звука" для каждого типа FUNCTION издать_звук_собака(данные: ДанныеСобаки) ВЫВОД данные.имя + " говорит: Гав!" END FUNCTION FUNCTION издать_звук_кошка(данные: ДанныеКошки) ВЫВОД данные.имя + " говорит: Мяу!" END FUNCTION FUNCTION издать_звук_утка(данные: ДанныеУтки) ВЫВОД данные.имя + " говорит: Кря!" END FUNCTION // Полиморфная функция, которая вызывает нужную реализацию FUNCTION заставить_издать_звук(сущность: Сущность) ЕСЛИ сущность.тип == СущностьТип.СОБАКА ТО издать_звук_собака(ПРЕОБРАЗОВАТЬ сущность.данные В ДанныеСобаки) ИНАЧЕ ЕСЛИ сущность.тип == СущностьТип.КОШКА ТО издать_звук_кошка(ПРЕОБРАЗОВАТЬ сущность.данные В ДанныеКошки) ИНАЧЕ ЕСЛИ сущность.тип == СущностьТип.УТКА ТО издать_звук_утка(ПРЕОБРАЗОВАТЬ сущность.данные В ДанныеУтки) КОНЕЦ ЕСЛИ END FUNCTION // Использование данные_рекса = СОЗДАТЬ ДанныеСобаки С (имя="Рекс", порода="Овчарка") рекс = СОЗДАТЬ Сущность С (тип = СущностьТип.СОБАКА, данные = АДРЕС(данные_рекса)) данные_мурзика = СОЗДАТЬ ДанныеКошки С (имя="Мурзик", цвет_шерсти="Рыжий") мурзик = СОЗДАТЬ Сущность С (тип = СущностьТип.КОШКА, данные = АДРЕС(данные_мурзика)) // Список "сущностей" (аналог массива объектов базового типа в ООП) животные = [рекс, мурзик] ДЛЯ КАЖДОГО животное ИЗ животные: заставить_издать_звук(животное) // Вывод: // Рекс говорит: Гав! // Мурзик говорит: Мяу! КОНЕЦ ДЛЯ
Этот пример для полиморфизма подтипов имитирует то, что в С можно было бы сделать с помощью структур с тегами (tagged unions) и
switch
или таблицей указателей на функции. В ООП-языках это делается элегантнее через виртуальные методы.
Резюме:
Полиморфизм — это способность кода вести себя по-разному в зависимости от типов данных.
Нет единого определения из-за различий в парадигмах, уровнях абстракции и исторических реализациях.
-
Основные виды (обобщенно):
Ad-hoc: Разное поведение для разных конкретных типов, определенное специально (перегрузка).
Параметрический: Один код работает единообразно с разными типами, тип передается как параметр (generics/шаблоны).
Подтипов: Код, написанный для базового типа, может работать с производными типами, выполняя их специфическую реализацию (динамическое связывание).
Понимание этих видов помогает увидеть полиморфизм как более широкую концепцию, не ограниченную только ООП.
Dhwtj
17.05.2025 07:41И про утиную типизацию как я её вижу
Утиная типизация предлагает гибкий подход к определению и использованию объектов, фокусируясь на их поведении (методах и свойствах), а не на иерархии наследования.
Ключевые моменты
* Контракт через поведение: Сущность определяется тем, что она делает, а не тем, чем она является формально (к какому классу или типу принадлежит).
* Минимальные зависимости: Код, использующий утиную типизацию, зависит только от той части "контракта" (набора методов/свойств), которая ему действительно необходима. Это снижает связанность (coupling) между компонентами системы.
* Гибкость "нарезки" контракта: Сущность, реализующая определённое поведение, не обязана знать обо всех возможных способах использования этого поведения. Разные части системы могут "видеть" и использовать разные подмножества её возможностей.
Это делает код более адаптивным к изменениям и упрощает повторное использование компонентов в различных контекстах.
Lewigh Автор
17.05.2025 07:41Насколько, что проще спросить LLM
И про утиную типизацию как я её вижу
Ваше мнение или очередной текст от LLM?
Dhwtj
17.05.2025 07:41Понимаешь...
В голове есть образ, правила и понимание. И статья в них не укладывается. Но выражать самому
Лень
Не удобно на телефоне
Я вспоминаю только фрагмент
Я очень тщательно подбираю термины, поэтому прошу LLM сформулировать в общепринятых терминах, чтобы всем было понятно. Единый язык (Ubiquitous Language) нужно строго блюсти.
Поэтому идея моя, указание на приоритеты моё, небольшое дополнение от LLM по терминам, связанности и полноте.
Да, утиная типизация удобна
ООП
Класс при создании обещает набор интерфейсов. И он должен их все знать в момент написания класса. Это протекает абстракция.
Утиная
Класс при создании образа ничего не знает о том как его контракт будут упрощать. Не зависит от использования.
С утиной
class MultiTool: def cut(self): print("MultiTool: Cutting...") def screw_in(self): print("MultiTool: Screwing in...") def hammer(self): print("MultiTool: Hammering...") class SimpleKnife: def cut(self): print("SimpleKnife: Cutting sharply!") # Этой функции нужен объект, который просто умеет резать. # Ей не важно, что это за объект или какие у него еще есть методы. def perform_cutting_action(cutter_tool): print("Preparing to cut.") cutter_tool.cut() # Утиная типизация в действии! print("Cutting finished.") super_tool = MultiTool() basic_knife = SimpleKnife() perform_cutting_action(super_tool) print("---") perform_cutting_action(basic_knife) # MultiTool не "знал", что его будут использовать только для резки. # SimpleKnife тоже просто реализует нужный метод. # Абстракция "умение резать" возникает по факту использования.
И без утиной
// Представим, что этот интерфейс мы придумали ПОЗЖЕ, // когда нам понадобилась абстракция "резака" interface ICutter { void cut(); } // Изначальный класс MultiTool НЕ знал об ICutter class MultiTool /* изначально НЕ implements ICutter */ { public void cut() { System.out.println("MultiTool: Cutting..."); } public void screwIn() { System.out.println("MultiTool: Screwing in..."); } public void hammer() { System.out.println("MultiTool: Hammering..."); } } class SimpleKnife implements ICutter { // Нож сразу сделали под интерфейс @Override public void cut() { System.out.println("SimpleKnife: Cutting sharply!"); } } public class ToolUsage { // Эта функция ТРЕБУЕТ объект, реализующий ICutter public void performCuttingAction(ICutter cutterTool) { System.out.println("Preparing to cut."); cutterTool.cut(); System.out.println("Cutting finished."); } public static void main(String[] args) { ToolUsage usage = new ToolUsage(); MultiTool superTool = new MultiTool(); SimpleKnife basicKnife = new SimpleKnife(); // usage.performCuttingAction(superTool); // ОШИБКА КОМПИЛЯЦИИ! // MultiTool не реализует ICutter. // Абстракция "протекает" - мы не можем использовать superTool как ICutter, // хотя у него ЕСТЬ метод cut(). // Нам нужно было бы ИЗМЕНИТЬ MultiTool или создать АДАПTER. usage.performCuttingAction(basicKnife); // Это сработает } }
Dhwtj
17.05.2025 07:41А из статически типизированных распространенных утиную умеет только Go, Typescript
// Определяем "форму" того, что нам нужно - объект с методом cut() interface ICutter { cut(): void; } class MultiTool { name: string; constructor(name: string) { this.name = name; } cut(): void { console.log(`${this.name}: MultiTool: Cutting...`); } screwIn(): void { console.log(`${this.name}: MultiTool: Screwing in...`); } hammer(): void { console.log(`${this.name}: MultiTool: Hammering...`); } } class Chainsaw { model: string; constructor(model: string) { this.model = model; } cut(): void { console.log(`Chainsaw ${this.model}: VRRROOOM... cutting!`); } startEngine(): void { console.log(`Chainsaw ${this.model}: Engine started.`); } } class Pliers { // У этого класса НЕТ метода cut() grip(): void { console.log("Pliers: Gripping tightly."); } } // Этой функции нужен объект, который соответствует "форме" ICutter. // Ей не важно, что это за класс или какие у него еще есть методы. function performCuttingAction(cutterTool: ICutter): void { console.log("Preparing to cut."); cutterTool.cut(); // Статическая проверка: cutterTool должен иметь метод cut() console.log("Cutting finished."); } let myMultiTool = new MultiTool("SwissArmy"); let myChainsaw = new Chainsaw("Stihl 250"); let myPliers = new Pliers(); performCuttingAction(myMultiTool); // OK! MultiTool имеет метод cut(), соответствующий ICutter console.log("---"); performCuttingAction(myChainsaw); // OK! Chainsaw имеет метод cut() console.log("---"); // performCuttingAction(myPliers); // ^ ОШИБКА КОМПИЛЯЦИИ (в TypeScript): // Argument of type 'Pliers' is not assignable to parameter of type 'ICutter'. // Property 'cut' is missing in type 'Pliers' but required in type 'ICutter'. // MultiTool и Chainsaw не писали "implements ICutter", но они СТРУКТУРНО совместимы. // Это очень близко к утиной типизации, но с проверкой на этапе компиляции.
DarthVictor
17.05.2025 07:41А по-моему нормальная статья. И примеры на нормальных языках программирования в ней намного нагляднее, чем приведенном вами
нейросблёвепримере, который ничего не объясняет именно из-за отсутствия примеров кода.P.S. То что вы обзываете статической утиной типизацией принято называть структурной типизацией. Помимо Go она также есть в TypeScript, а в C# её элементы есть в делегатах. Также в большинстве случаев для описания функциональных типов используется именно структурная типизация (никто не требует от анонимной функции в Java / C# объявлять реализуемый интерфейс явным образом, только соответствовать сигнатуре).
Dhwtj
17.05.2025 07:41Утиная как требование: класс/тип не должен знать о существовании конкретных интерфейсов. Он просто выставил свой контракт. Когда мы работаем с набором элементов которые крякают мы не хотим знать весь контракт, знаем только что они крякают. Ведь контракт может измениться, но пока он содержит кря наш набор будет обработан верно.
это ключевое преимущество! Объект может эволюционировать, приобретать новые методы, но пока он сохраняет методы, соответствующие требуемому интерфейсу ("кряканье"), он остается совместимым с кодом, который на этот интерфейс полагается. Это способствует лучшей поддержке и развитию ПО
Утиная как требование, Структурная как реализация.
-
nin-jin
17.05.2025 07:41Прекрасная статья. Всё чётко и без воды.
Я бы ещё добавл, что у функции в общем случае есть две группы параметров: рантайм и компайлтайм. И те и другие могут быть как значениями, так и типами, и даже алисами для внешних кусков кода. Кроме того, некоторые функции могут вызываться и при компиляции, если в этот момент известны их рантайм параметры.
Lewigh Автор
17.05.2025 07:41Прекрасная статья. Всё чётко и без воды.
Я бы ещё добавл, что у функции в общем случае есть две группы параметров: рантайм и компайлтайм. И те и другие могут быть как значениями, так и типами, и даже алисами для внешних кусков кода. Кроме того, некоторые функции могут вызываться и при компиляции, если в этот момент известны их рантайм параметры.
Спасибо за такую оценку. Я намеренно упустил момент связанные с представлением типов во время компиляции и в рантайме чтобы не перегружать статью. Есть планы, как дойдут руки, в отдельной статье коснутся темы как оно "под капотом" устроено.
Vitaly_js
17.05.2025 07:41Как бы не выглядели языки программирования, обычно они используется схожий подход - есть данные и есть функции которые к данным применяются.
Строго говоря, это не так. У функциии в программировании есть самостоятельное значение. Тут бы ближе по смыслу был термин "операция".
Некоторые предполагают что ООП отличается от других парадигм, ведь там есть объекты а у объектов есть методы. Методы - это особые функции привязанные к объекту. На деле же ничего особого в них нет и это не более чем удобный синтаксический сахар над обычными функциями.
Чисто по определению, парадигма - это модель постатовки задач и их решение. Поэтому да, одна парадигма отличается от другой. То, что разные парадигмы можно применять к одному и тому же ЯП (языку программирования) никак не отменяет их сути.
С этим надеюсь разобрались. Методы - это обычный синтаксический сахар над функциями.
По моему, вы очень вольно обошлись с понятием синтаксический сахар. Введение объектов меняет поведение программы в которой объектов не было. Поэтому объекты, методы не являются синтаксическим сахаром. Это базовые возможности соответствующих ЯП, которые имеют собственную реализацию.
Следующее упрощение заключается в том что операторы и конструкторы - это тоже обычные функции. Разнообразные языки программирования могут по разному представлять работу с этим инструкциями, но суть это не меняет.
На самом деле меняет. Например возьмет JS там есть очереди функций, очереди микро задач, макро задач, и ни в одну из них не помещаются, например, арифметические операции.
Так что бы вы понимали, есть такой язык как Ассемблер (собственно в него и компилируются языки высокого уровня) и для того, что бы сложить два числа на ЦПУ нужно их поместить в два регистра, а результат будет на месте первого операнда. Операция выполнится за такт. Поэтому и существуют и операторы, например, математические, логические, а есть функции.
Все эти способы записи просто вариации синтаксиса над вызовом функции. Не смущайтесь увидев знаки + - > и т.д. - от имен обычных функций они ничем не отличаются.
Как мы выяснили, на самом деле это не так. Далеко не каждый оператор является функцией. Особенно это относится к тем операторам, которые вы перечислили. Вот все таки стоило в самом начале дать ясное определение функции.
Надеюсь теперь стало понятно что оператор - это такая же функция с особым синтаксисом.
То, что в ЯП высокого уровня можно перегружать операторы вовсе не означает, что все операторы используют внутренний механизм функций для своего существования.
Дальше идет просто нечто. Несмотря на то, что все это имеет место быть, в рамках статьи, по моему, это подано не с той точки зрения.
Вы навалили: полиморфизм, мономорфная, полиморфная, специальная полиморфная, универсальная полиморфная функции. Все это в таком виде перекликается с мыслью, которую вы изложили в начале, ну, т.е. что все это поток сознания.
Я интереса ради загуглил эту тему и гугл выдал вполне ясный ответ:
Типы полиморфизма: Существуют разные виды полиморфизма
Подтиповой полиморфизм (Polymorphism by Subtypes): Обработка объектов различных классов, которые связаны отношениями наследования.
Параметрический полиморфизм (Parametric Polymorphism): Использование обобщенных типов, которые позволяют работать с данными различных типов, не указывая их конкретно при определении функции.
Абстрактный полиморфизм (Abstract Polymorphism): Различные реализации одного и того же интерфейса, позволяющие выполнять различные действия в зависимости от конкретного объекта.
Ниже вы все это так же распишете.
Тип данных представляет из себя множество допустимых значений. Например логический тип это два возможных варианта или 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 }.
Вроде и написано нормально. Но вот представлять 32 битный целочисленный примитив в виде множества допустимых значений. Тут то как раз суть в том, что любое значение будет представлено 32 битным чилом, т.е. и 1 и 100000 - это всегда 32 битное число. В общем, звучит как то странно.
Как вы могли заметить, полиморфизм - это не про ООП, это история о том как различными способами дать возможность функциям работать гибко с данными разных типов.
Да в общем-то нет. Именно из-за того, что теперь полиморфизм - это в том числе и про ООП мы получаем что некоторые виды полиморфизма относятся только к ООП.
PeeWeee
КДПВ настолько классная, что пришлось прочесть статью :)