Существует множество мнений о возможности применения подходов Объектно-Ориентированного Программирования (ООП) и паттернов в Rust. Кто-то считает, что полноценного ООП в Rust нет и быть не может. Другие разработчики, наоборот, высказывают мнение, что элементы языка позволяют использовать приёмы из ООП именно так, как их видели создатели этой парадигмы.
В данной статье я постараюсь продемонстрировать, какие идеи и принципы из ООП позволяет реализовать Rust, и как это работает на простых примерах.
Определение
Для начала рассмотрим определение из англоязычной википедии. В русскоязычной википедии базовое определение имеет другой смысл и плохо стыкуется с тем, что обычно описывают в литературе, как и с моим личным пониманием данной парадигмы.
Object-oriented programming (OOP) is a programming paradigm based on the concept of "objects", which can contain data and code: data in the form of fields (often known as attributes or properties), and code, in the form of procedures (often known as methods).
В Rust для описания объектов используются структуры. Они позволяют упаковать в объект нужные ему данные (поля) и наделить объект необходимым функционалом (методами). Отлично! Под базовое определение попали. Идём дальше.
Принципы
Давайте рассмотрим каждый принцип и подумаем, какие возможности предоставляет Rust для их реализации:
Инкапсуляция — сокрытие внутренней реализации объекта от внешнего пользователя. В Rust эта идея реализуется с помощью приватных полей и методов структур, используя механизм модулей. Если поле или метод в структуре не помечен как публичный, то для любого внешнего модуля это поле является скрытым и не может быть использовано. Более того, чтобы саму структуру было видно извне её модуля, её тоже нужно помечать, как публичную. Пример:
mod aaa {
fn foo(inner: bbb::Inner) {
// Есть доступ к публичному полю.
let a = inner.public;
// Ошибка компиляции: попытка обращения к приватному полю.
let b = inner.private;
// Ошибка компиляции: попытка использования приватной структуры.
let c = bbb::Private {};
}
mod bbb {
pub struct Inner {
private: i32,
pub public: i32,
}
struct Private {}
}
}
Наследование описывает отношение "является" между двумя объектами. Например, "собака является млекопитающим" или "круг является фигурой". При этом, дочерний объект может быть использован в любом контексте, в котором ожидается родительский объект. Для этого необходимо, чтобы функционал базового объекта присутствовал, также, и в дочернем. Тут в Rust начинают появляться отличия от классического подхода к реализации данной идеи — через классы и интерфейсы. Во-первых, в Rust отсутствует наследование структур, а, следовательно, и наследование данных. Вместо этого в языке есть механизм для описания функционала в отрыве от конкретной реализации. Этот механизм называется трейты. Их могут наследовать как структуры, так и другие трейты. Пример:
trait Shape {
// У любой фрормы можно посчитать площадь.
fn area(&self) -> f32;
}
trait HasAngles: Shape {
// У любой фигуры с углами можно посчитать количество углов.
fn angles_count(&self) -> i32;
}
struct Rectangle {
x: f32,
y: f32,
}
// Прямоугольник является формой.
impl Shape for Rectangle {
fn area(&self) -> f32 {
self.x * self.y
}
}
// Прямоугольник является фигурой с углами.
impl HasAngles for Rectangle {
fn angles_count(&self) -> i32 {
4
}
}
struct Circle {
r: f32,
}
// Круг является формой
impl Shape for Circle {
fn area(&self) -> f32 {
self.r.powi(2) * PI
}
}
-
Следующий принцип используется как раз для того, чтобы дать возможность использовать в данном контексте любой тип, который является наследником заданного родителя. Этот принцип называют полиморфизмом. Rust поддерживает сразу два вида полиморфизма: статический и динамический. Оба подхода решают одну проблему, но каждый имеет свои особенности.
Статический полиморфизм требует, чтобы при компиляции программы было известно, какие конкретные типы используются в каждом обобщённом контексте. Имея эту информацию компилятор проводит, так называемую, мономорфизацию. Когда одна обобщённая сущность превращается в несколько сущностей с конкретными типами, используемыми в них. При этом размер исполняемого файла раздувается и мы теряем возможность подменять наследника в процессе выполнения программы. Взамен получаем более высокую скорость выполнения, так как компилятору известны конкретные типы и адреса функций для каждой ситуации, а следовательно, он может применять больше оптимизаций.
Динамический полиморфизм работает посредством динамической диспетчеризации. При этом мы не знаем конкретного типа объекта и для получения адреса его методов в памяти используем дополнительную информацию — таблицу функций. Её содержимое зависит от того, какой конкретный тип скрывается за абстрактным указателем. Такой подход не раздувает исполняемый файл и позволяет подменять реализацию в процессе выполнения программы. Но при этом мы жертвуем частью производительности — для вызова метода нам придётся сначала прочитать его адрес из памяти, что значительно затрудняет оптимизацию программы на этапе компиляции.
Пример статического полиморфизма:
// Принимаем что угодно, реализующее трейт Shape.
fn areas_sum(shape1: impl Shape, shape2: impl Shape) -> f32 {
shape1.area() + shape2.area()
}
fn foo(rectangle: Rectangle, circle: Circle) {
// Можем передать две разные фигуры.
let sum = areas_sum(rectangle, circle);
}
Пример динамического полиморфизма:
// Принимаем что угодно, реализующее трейт Shape.
// В этот раз принимаем не сами объекты, а ссылки на них,
// так как не зная конкретный тип объекта, мы не знаем и его размер,
// а следовательно, не сможем выделить для него место на стеке.
fn areas_sum(shape1: &dyn Shape, shape2: &dyn Shape) -> f32 {
shape1.area() + shape2.area()
}
fn foo(rectangle: Rectangle, circle: Circle) {
// Можем передать ссылки на две разные фигуры.
let sum = areas_sum(&rectangle, &circle);
}
Последний принцип не всегда указывают, так как он, в некотором смысле, следует из предыдущих. Это абстракция — способность скрывать детали различных реализаций некоторого функционала под общим интерфейсом и, затем использовать их в общем для всех реализаций коде. Собрав воедино код всех примеров с формами мы получим пример абстракции.
Заключение
Мы рассмотрели различные элементы Rust, подходящие под определение ООП и реализующие основные принципы этой парадигмы. Структуры, трейты и обобщённое программирование позволяют создавать абстракции и описывать общую логику их работы. Это открывает возможности для использования не только паттернов проектирования для решения конкретных задач, но и целых архитектурных концепций (MVC, MVP, MVVM, ...), в, практически, исконном их виде.
25 апреля в OTUS состоится открытое занятие «Какие проблемы решает Rust?», на котором обсудим особенности Rust, выделяющие его на фоне других языков, его преимущества и недостатки. Ответим на вопрос, что он может предложить современной индустрии. Регистрация для всех желающих по ссылке.
Комментарии (25)
amarao
20.04.2022 18:03+1Вообще говоря, вместе с dyn trait, мы получаем настоящий эквивалент С++ классов (... окей, в том объёме как я его знаю, ибо С++##22 неисчерпаем как атом). virtual table есть? Остаётся вопрос с наследованием, и это как раз сущность, которую я стараюсь не использовать даже в языках, где она есть и факультативная, потому что чтение кода между двумя "разно наследованными" классами от одного базового приводит к stack overflow у кожанного мешка.
F3kilo Автор
21.04.2022 10:59Не совсем эквивалент... Наверное, если запретить базовым классам в плюсах иметь данные, то будет похоже.
А вообще, согласен. Наследование нужно только тогда, когда оно, действительно, упрощает жизнь именно в контексте конкретной задачи. Хорошая задача на наследование: стоит ли квадрат наследовать от прямоугольника.
С одной стороны, квадрат, действительно является прямоугольником, и, казалось бы, что может пойти не так?) Но, если в нашем контексте мы ожидаем, что для абстракции "прямоугольник", увеличение одной стороны пропорционально увеличивает площадь, то для квадрата это не сработает. В данном контексте нарушится LSP. Поэтому не нужно слепо руководствоваться внутренними ощущениями или сторонними фактами, продумывая архитектуру. Надо смотреть насколько то или иное решение стыкуется с конкретной задачей.hapcode
21.04.2022 14:30-2Но квадрат не является прямоугольником, а всего лишь обладает характеристикой прямоугольника — прямые углы. Естественно, при наследовании от прямоугольника будут проблемы с LSP. Т.к. наследование подразумевает получение квадратом всех характеристик прямоугольника, что не является верным.
nin-jin
21.04.2022 16:03-2Отношение подтипизации может быть реализовано разными способами:
Через сужение типа: квадрат является частным случаем прямоугольника
Через расширение типа: прямоугольник является квадратом с дополнительным значением размера
Через пересечение: квадрат и прямоугольник являются подтипами абстрактной фигуры, характеризуемой координатами
Через объединение: квадрат и прямоугольник являются независимыми типами, а вот плита может быть и квадратом, и прямоугольником в зависимости от ракурса.
hapcode
21.04.2022 16:43Я говорил про наследование. Жесткое отношение «is-a». Какой смысл наследовать квадрат от прямоугольника, если не получается соблюдать инвариант пропорциональности квадрата? Квадрат не ведет себя полностью как прямоугольник. Не получится задать ему разную высоту и ширину как для прямоугольника.
0xd34df00d
21.04.2022 18:24+1Иммутабельный квадрат как раз является прямоугольником. Вы просто для квадрата возвращаете новый прямоугольник.
Antervis
20.04.2022 20:08-2Наследование описывает отношение "является" между двумя объектами
Нет, это полиморфизм описывает отношение "является". Наследование именно про переиспользование логики и данных, в общем случае объект потомка не обязан "являться" корректным экземпляром родителя.
sshikov
20.04.2022 22:46funca
20.04.2022 23:46-1Наследование в стиле C++ это в первую очередь техника повторного использования кода, чтобы не писать по десять раз одно и тоже, с возможностью подменять отдельные запчасти.
LSP это просто популярный мем, ни какой особой роли он там не играет.
SadOcean
21.04.2022 02:16+1Так а ООП то тут при чем?
От того, что наследование реализаций классов есть в плюсах и нет в расте и интерфейсах - как это ООП мешает?
Отдельный вопрос, что наследование реализаций рекомендуют не использовать, потому что оно порождает хрупкий код и ломает инкапсуляцию
funca
21.04.2022 06:22+2ООП не существует в вакууме. Вокруг него сложилась определенная культура с классами, SOLID, паттернами и т.п. Заявка о поддержке ООП создаёт у разработчиков определенные ожидания, которые превращаются в разочарования и сорванные сроки, если что-то вдруг идёт не так.
JavaScript почти 20 лет прямым текстом всем доказывали, что наследование прототипов это тоже ООП. Но все успокоились только после того, как в язык завезли классы.
В Rust аналогичная картина. Однако они не позиционируют себя как язык с полноценной поддержкой ООП, а честно предупреждают об особенностях, компромиссах и возможностях сделать иначе и лучше https://doc.rust-lang.org/book/ch17-00-oop.html
SadOcean
21.04.2022 11:45Справедливости ради, да, в растбуке хорошо написано про эдж кейсы
Это скорее мое мнение, мне кажется, что это инструменты, которые используются вместе с ООП
А для организации объектов все есть.
sshikov
21.04.2022 19:45+1Во-первых, я бы отметил, что LSP это вообще не про ООП, он про типы и подтипы, а они бывают много где. И в отличие от других паттернов из того же SOLID, нарушение LSP как раз очень часто просто сломает существующий код, не знающий про другое поведение наследников.
>объект потомка не обязан «являться» корректным экземпляром родителя
Вот такой случай как раз и есть нарушение LSP. И любой клиент, который заложится на корректность поведения, может сломаться.
Ну т.е. я к чему клоню — вы разумеется можете написать наследника так, что он будет нарушать все подряд, и не будет корректным экземпляром родителя. И будет работать. Это возможно, и даже не сложно, до тех пор, пока вы свой код полностью помните и понимаете, и знаете, где, что и как используется. Но в больших проектах это не всегда бывает.
Antervis
21.04.2022 03:40-2LSP это рекомендация по проектированию архитектуры приложения. Она не диктует ни то, что такое наследование, ни то, как оно может применяться. Повторюсь: я говорил про общий случай, а не распространенную конкретику.
Простейший пример на c++:
class Derived : Base {};
Ну-ка, кто скажет почему при таком наследовании нет полиморфизма?
funca
21.04.2022 08:21+2The intuitive idea of a subtype is one whose objects provide all the behavior of objects of another type (the supertype) plus something extra. What is wanted here is something like the following substitution property [S]: If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2, then S is a subtype of T.
...We are using the words “subtype” and “supertype” here to emphasize that now we are talking about a semantic distinction. By contrast, “subclass” and “superclass” are simply linguistic concepts in programming languages that allow programs to be built in a particular way.
В своей работе из 1987 Барбара и сотоварищи думали над формализацией отношения тип-подтип и в качестве отправной точки взяли определение из более ранней работы своего коллеги. Позднее они нашли в таком определении изъяны и пытались исправить, усложнив определение этого своего behaviour subtyping. Но в целом данная ветка исследований оказалась не особо практичной (как многое другое, пытающееся опираться на семантику внешнего мира). Сейчас все это представляет собой больше историческую ценность, может быть дающую какие-то инсайты и возможность не наступать на уже кем-то пройденные грабли. То как подаётся LSP в SOLID для адептов ООП это каргокульт в чистом виде.
sshikov
21.04.2022 19:49>возможность не наступать на уже кем-то пройденные грабли
А вы считаете, этого мало? Ну в смысле, опять же — если вы умный, высококвалифицированный и т.п., и не наступаете и так (отчего бы и нет, вполне верю), это же не значит, что своему наследнику (человеку) нужно оставить проект, который он сломает, потому что окажется не таким умным, квалифицированным и т.п.? Разумеется, LSP не серебряная пуля, это в целом не более чем хорошая практика, если так делать — вы скорее всего не сломаете проект добавляя наследников.Antervis
21.04.2022 20:55вы спорите об эффективности LSP исходя из предположения, что перед нами всегда стоит задача, которую он решает. Спор же был как раз не о таких случаях. Какой смысл в LSP если мы вообще не собираемся использовать потомка в качестве родителя? Наследование ведь может являться и просто деталью реализации объекта.
sshikov
21.04.2022 21:09Я скорее возражал против некоторой расплывчатости вашей формулировки в первом комментарии данной ветки. Да, сейчас вижу что там было и про «в общем случае» — и с таким уточнением скорее согласен.
SadOcean
21.04.2022 02:25+6В целом мне кажется, что раст можно назвать вполне поддерживающим ОО подход.
Несмотря на отсутствия наследования реализаций (а это не единственный вариант организации связей) и некоторые особенности, в нем есть все нужное для организации кода в оо стиле - разбиение кода на независимые объекты со своим скрытым сложным внутренним состоянием и методами их обработки.
Daniil_Palii
trait - это ведь интерфейс? То есть в расте есть только наследование от интерфейса?
Cheater
Похож, концепцию реализует ту же самую, но шире по возможностям (если сравнивать например с Java interface). Вот хорошее сравнение:
StackOverflow - Is Rust trait the same as Java interface
Вопрос имхо не корректен.:) Есть "наследование от интерфейса" (trait implementation), есть ряд других техник для выполнения концепции наследования. Данные "наследуются" через композицию (Composition over inheritance). Трейты можно наследовать (Rust docs: Supertrait), но это не наследование интерфейсных классов в обычном понимании. Наследования в классическом смысле в Rust нет.