Типы в программировании как математические множества
Типы в программировании можно (и нужно) рассматривать как математические множества.
Мысль хоть и очевидная, но из моей головы давно выветрилась.
Именно поэтому я и решил написать эту статью: чтобы напомнить о ней самому себе и тем, кто о ней тоже забыл или даже не знал.
Сначала вспомним главное определение:
Множество — это коллекция элементов, обладающих общим свойством, которые рассматриваются как единое целое. Элементы множества могут быть любыми: числами, объектами, символами и т.д.
1. Множество целых чисел: {1, 2, 3, 4}
2. Множество гласных букв русского алфавита: {А, Е, И, О, У, Ы, Э, Ю, Я}
В программировании мы часто думаем о типах данных как о чём-то, что отличается от математических множеств. Но если посмотреть на типы данных по-другому, это может расширить наше понимание того, как они устроены и связаны друг с другом.
Давайте разберемся чуть подробнее.
Примеры буду приводить на языке C#, однако их можно воспринимать как псевдо-код
Целочисленные типы как множества чисел
Рассмотрим целочисленные типы.
Мы можем представлять их как множества чисел с определёнными диапазонами:
Напоминание
x ∈ ℤ следует понимать как «x принадлежит множеству целых чисел»
sbyte
: {x ∈ ℤ | -128 ≤ x ≤ 127}short
(Int16): {x ∈ ℤ | -32,768 ≤ x ≤ 32,767}int
(Int32): {x ∈ ℤ | -2,147,483,648 ≤ x ≤ 2,147,483,647}long
(Int64): {x ∈ ℤ | -9,223,372,036,854,775,808 ≤ x ≤ 9,223,372,036,854,775,807}
С такой точки зрения, каждый целочисленный тип является подмножеством следующего более крупного типа.
Здесь тип short
является подмножеством типа int
, который, в свою очередь, является подмножеством типа long
.
Отношения подмножеств и преобразование типов
Понимание типов как множеств помогает лучше понять, почему некоторые приведения типов безопасны, а другие нет. Например:
// Безопасное приведение от short к int: каждый элемент множества "short"
// также является допустимым элементом для множества "int".
short s = 1000;
int i = s;
Однако обратное не всегда верно:
// Небезопасное приведение: требует явного приведения типов и может привести
// к потере данных, поскольку не каждый элемент множества "int"
// является элементом множества "short".
int i = 40000;
short s = (short)i;
Целочисленные типы как множества чисел наглядно
Тип bool как множество
Тип bool
в C# можно рассматривать как множество с ровно двумя элементами:
bool
: {true, false}
Типы перечислений как конечные множества
Перечисления в C# — отличные примеры конечных множеств. Например:
enum DaysOfWeek
{
Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday
}
Перечисление DaysOfWeek
можно рассматривать как множество: {Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday}.
Сложные типы как множества
Давайте посмотрим, как можно воспринимать и более сложные типы как множества.
Пример с транспортом
Перед нам иерархия типов транспорта:
class Vehicle
{
public string Make { get; set; }
public string Model { get; set; }
}
class Car : Vehicle
{
public int NumberOfDoors { get; set; }
}
class AudiCar : Car
{
public string AudiSpecificFeature { get; set; }
}
Посмотрим на эти типы как на множества:
Vehicle
: Множество всех возможных транспортных средств с маркой и моделью.Car
: ПодмножествоVehicle
включающее все транспортные средства с дверьми.AudiCar
: ПодмножествоCar
включающее все автомобили с особенностями, характерными для Audi.
Напоминание
A ⊂ B следует понимать как «множество A является подмножеством B».
В терминах множеств это будет выглядеть так:
Vehicle
= {x | x имеет марку и модель}Car
= {x ∈ Vehicle | x имеет двери}AudiCar
= {x ∈ Car | x имеет особенности, характерные для Audi}
Можно заключить, что AudiCar
⊂ Car
⊂ Vehicle
.
Пример с электронными устройствами
Рассмотрим иерархию электронных устройств:
class ElectronicDevice
{
public string PowerOn() {}
}
class Smartphone : ElectronicDevice
{
public string MakeCall() {}
public string SendText() {}
}
class DualCameraSmartphone : Smartphone
{
public string TakePhoto() { }
}
Мы также можем рассматривать эти типы как множества:
ElectronicDevice
: Множество всех возможных устройств, которые могут включаться.Smartphone
: ПодмножествоElectronicDevice
, которое включает все устройства, способные совершать звонки и отправлять сообщения.DualCameraSmartphone
: ПодмножествоSmartphone
, включающее все смартфоны с возможностью делать качественные фотографии с использованием двойной камеры.
В терминах множеств:
ElectronicDevice
= {x | x может быть включен}Smartphone
= {x ∈ElectronicDevice
| x может звонить и отправлять сообщения}DualCameraSmartphone
= {x ∈Smartphone
| x может делать фото}
Соответственно, DualCameraSmartphone
⊂ Smartphone
⊂ ElectronicDevice
.
Пример с интерфейсами
Интерфейсы также могут быть рассмотрены с точки зрения множеств.
Например:
interface IComparable
{
int CompareTo(object obj);
}
interface IEquatable
{
bool Equals(T other);
}
Мы можем представить интерфейс IComparable
как множество всех объектов, которые имеют метод CompareTo(object obj)
, а интерфейс IEquatable
— как множество всех объектов, которые имеют метод Equals(T other)
.
Класс, реализующий несколько интерфейсов, можно рассматривать как принадлежащий пересечению этих множеств:
class CompareableInt : IComparable, IEquatable
{
public int Value { get; set; }
public int CompareTo(object obj) {}
public bool Equals(int other) {}
}
Напоминание
A ∩ B следует понимать как «пересечение множеств A и B».
В терминах множеств, CompareableInt
принадлежит к IComparable
∩ IEquatable
.
Принцип подстановки Барбары Лисков
Принцип подстановки Барбары Лисков (буква L из SOLID) является фундаментальным принципом объектно-ориентированного программирования, который тесно связан с нашим представлением о типах как о множествах.
Принцип гласит, что объекты в программе должны быть заменяемы экземплярами их подтипов без изменения корректности программы.
С точки зрения множеств, это означает, что каждый элемент множества A должен вести себя так, как ожидается от элементов более широкого множества B, если A ⊂ B.
Рассмотрим знаменитый пример нарушения этого принципа:
class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int Area() => Width * Height; }
}
class Square : Rectangle
{
public override int Width
{ set { base.Width = base.Height = value; } }
public override int Height
{ set { base.Width = base.Height = value; } }
}
Изюминка:
Вот здесь наш Debug.Assert () будет вести себя по разному в зависимости от того, объект какого типа на самом деле был передан в метод — Rectangle
или Square
.
void IncreaseWidth(Rectangle rectangle)
{
int originalHeight = rectangle.Height;
rectangle.Width += 1;
Debug.Assert(rectangle.Height == originalHeight); // Для квадрата это будет неверно.
}
Чтобы соблюдать LSP, нам нужно гарантировать, что все операции, которые верны для элементов множества Rectangle
, также верны для элементов его подмножества Square
.
В данном примере кода эта гарантия не соблюдается, поэтому принцип нарушен.
Чего сказать-то хотел?
Сказать хотел, что если вы, как и я, погрязли в перекладывании DTO, настройке инфраструктуры, трекинге в Jira/Asana/Whatever, бесконечных созвонах/переписках и забыли, что программирование это, вообще-то, красиво, — попробуйте посмотреть на обыденные вещи (типы, наследование, интерфейсы и т.д.) с другой, непривычной точки зрения.
Послесловие
На самом деле, есть целая область, которая в том числе покрывает и то, о чем мы сегодня говорили.
Она называется — »Теория Типов».
Поэтому, если вас хоть немного заинтересовало то, чем я поделился, рекомендую продолжить изучение и нырнуть чуть глубже.
Начать можно, например, отсюда https://habr.com/ru/articles/758542/