Введение
Я, как разработчик, ранее работал в более классическим объектно-ориентированном подходе, где есть базовые классы, и от них наследуются подклассы, чтобы переиспользовать кодовую базу. Условный пример: объявить класс Animal, сделать класс Dog его потомком, унаследовав общие поля и методы.
Но если перейти на Rust, где классов нет вовсе, то необходимо будет вместо привычного наследования использовать "композицию". Ради справедливости стоит упомянуть цитату из классической книги GoF:
A common problem with object-oriented design is trying to force things into a is-a relationship, and neglecting has-a relationships. The GoF said "Prefer Composition to Inheritance" in their Design Patterns book
фразу "предпочитайте композицию - наследованию", разработчики, привыкшие к классическому ОО-проектированию, могут не до конца понимать, что она означает на практике. В этой статье, я детально разберу:
- что такое композиция
- почему современные языки вроде Rust делают на нее ставку
- и сравним с ОО-подходами в Dlang
Данные языки программирования я взял не просто так. За последнее время, мне необходимо плотно работать с Rust, и очень импонирует его экосистема с облегченной возможностью статичной линковки приложений. Dlang я взял, т.к. данный язык обогащен ОО-фичами, а языки как C# или Java разбирать неинтересно, т.к. в интернете и так существует достаточно обильное количество статей на данную тематику с ними.
Что такое композиция (простыми словами)?
Если говорить упрощенно, композиция - это подход в дизайне, при котором сложные объекты состоят из более простых объектов, вместо того чтобы являться их подклассами.
Можно провести аналогию: машина не является двигателем, поэтому наследовать условный класс Car от условного Engine - было бы странно. Можно представить это без жестко-связанной иерархии, что машина имеет двигатель, т.е. класс Car может содержать объект Engine в качестве поля - это и есть композиция.
Формально в ООП, композиция описывается как принцип, позволяющий достичь повторного использования кода без наследования. Вместо того, чтобы два подкласса брали общий функционал у родителя, они компонуются из отдельного объекта, реализующего этот общий функционал, включают его как поле и просто делегируют ему соответствующие вызовы методов. Таким образом, встраиваются нужные возможности через составление из компонентов, а не через иерархию классов.
Почему такие языки, как Rust делают упор на композицию?
В целом, стоит упомянуть, что подобные языки, как Rust могут вообще не иметь классов с их классическим наследованием, а предоставлять возможность использовать композицию. Дело в том, что наследование, при всей своей мощности, несет ряд проблем:
- наследование создает жесткую связь между базовым и производными классами, т.е. малейшее изменение в кодовой базе будет затрагивать все подклассы, усложняя общую поддержку
- глубокие иерархии оборачиваются хрупкостью дизайна, т.е. класс-потомок сильно зависит от деталей родителя, что снижает гибкость и переиспользуемость кода
- в целом, описанные проблемы, выше, выделяют общую проблему "плохого масштабирования" кодовой базы, а при росте проекта - это важно, т.к. крупные проекты могут содержать сотни-тысячи условных классов/объектов, и вопрос заложения правильного дизайна является важным
Композиция же позволяет избежать многих из этих ловушек и дает такие плюсы:
- более слабое связывание объектов
- более ярко-выраженное разделение ответственностей
Тут, можно вспомнить и Джеймса Гослинга (создатель Java), который говорил следующее:
I once attended a Java user group meeting where James Gosling (Java's inventor) was the featured speaker. During the memorable Q&A session, someone asked him: "If you could do Java over again, what would you change?" "I'd leave out classes," he replied. After the laughter died down, he explained that the real problem wasn't classes per se, but rather implementation inheritance (the extends relationship). Interface inheritance (the implements relationship) is preferable. You should avoid implementation inheritance whenever possible
то создатель Java предпочел бы убрать классы... И по-сути, проблема не в классах, как таковых, а именно в наследовании реализации. Естественно, интерфейсное наследование - предпочтительнее, и тут есть чем парировать языкам вроде C#/Java, которые предоставляют данные возможности. И по-сути, в Rust есть схожий механизм, которым являются traits (трейты).
Я бы, так же, добавил что композиция не является "панацеей". За все - нужно платить, и здесь за гибкость приходится расплачиваться увеличением объема кода и усложнением взаимодействий объектов, и будет больше проксирующих вызовов. Но, как по мне, у композиции более выигрышная ситуация на сегодня, в контексте возможности создать более кристально-чистую масштабируемую архитектуру проекта.
Объектно-ориентированный подход в Dlang
Для сравнения, взглянем на Dlang (или просто "D", но я буду в данной статье писать полное его имя). У Dlang есть все атрибуты классического ОО-языка: классы и поддержка наследования, базовым является класс Object, от него неявно наследуются все прочие (грубо говоря, аналогично C#/Java).
Наследование в Dlang - одиночное, т.е. у класса может быть только один непосредственный суперкласс (но, реализовать он может несколько интерфейсов), методы классов - виртуальные.
Как это выглядит в коде? Например, определим базовый класс и пару потомков:
Классы Dog и Cat наследуются от Animal, автоматически получая поле name и метод speak(). Переопределяем через override - метод speak() в потомках, чтобы выводить свои звуки. И здесь, показан базовый полиморфизм, где можно написать функцию, которая работает с базовым типом Animal, и передать ей экземпляр Dog или Cat, и будет вызван соответствующий вариант метода.
Таким образом, Dlang дает привычный для большинства разработчиков - механизм наследования для повторного использования кода с поддержкой классического полиморфизма. Если нужно расширить функциональность, просто идет наследование от базового класса и добавляются/переопределяются методы.
Композиция и трейты в Rust (альтернатива наследованию)
В Rust отсутствуют классы, но это не значит, что язык не поддерживает абстракцию, инкапсуляцию или полиморфизм. Просто, это достигается иначе.
Вместо базового класса Animal, здесь будет использоваться трейт Animal, который описывает требуемое поведение (метод speak). Конкретные сущности (Dog, Cat) являются обычными структурами и только реализуют этот трейт, но не являются наследниками общего базового типа.
Полиморфизм будет достигаться через Box<dyn Animal>, т.е. говоря грубо: указатель на "что угодно, что реализует трейт Animal". Это уже не иерархия классов, а композиция, т.е. мы собираем поведение из трейтов и данных из структур.
Чуть подробнее про конструкцию Box<dyn Animal>:
- Box<T> - это умный указатель из стандартной библиотеки Rust, который владеет значением в куче (heap): https://doc.rust-lang.org/book/ch15-01-box.html
- dyn %trait_name% - это ключевое слово, которое говорит: "здесь трейт-объект с динамической диспетчеризацией", то есть реальный тип - скрыт, мы знаем только, что он реализует трейт, а вызовы методов идут через таблицу виртуальных функций (vtable): https://docs.rs/vtable/latest/vtable/
Что же... Я показал базовый пример, давайте немного усложним и посмотрим на иной пример, где уже попробуем использовать абстрактный класс на стороне Dlang, и попробуем сделать аналог на Rust.
Абстрактный класс в Dlang vs. трейт в Rust
Для начала, давайте сразу вспомним определение абстрактного класса и подготовим пример на Dlang с его использованием.
Обычно, абстрактный класс - это класс, который нельзя создавать напрямую (нельзя сделать новый экземпляр от него), зачастую в языках программирования в которых он есть, он помечен ключевым словом - abstract. Абстрактный класс может содержать поля, конструкторы, обычные методы с реализацией и, так же, виртуальные методы, т.е. он умеет хранить и состояние, и часть общей логики для наследников.
Тут стоит и немного вспомнить про интерфейсы, в отличие от абстрактного класса, интерфейсы в языках программирования - это, как правило, "чистый контракт", они описывают только набор методов (сигнатуры), но как правило не хранят состояние (нет экземплярных полей). Класс может наследоваться только от одного базового класса, но при этом реализовывать несколько интерфейсов. Для меня это было важно подметить, единственное что уточню - я дал общее определение, в различных языках программирования могут быть нюансы, к примеру в последних версиях C#/Java добавили в интерфейсы возможность иметь default реализацию.
Теперь, сформируем пример с абстрактным классом на Dlang:
В Dlang примере у нас есть abstract class Shape, который и хранит общие поля, и дает реализацию. Но, как это будет выглядеть в Rust варианте?
Там, где мы делали "один абстрактный базовый класс с полями и реализацией", в Rust тот же подход я бы разложил на два слоя:
- ShapeCommon (данные)
- Shape (контракт и общие методы)
Повторное использование логики и состояния достигается не через наследование, а через композицию этой общей части внутрь конкретных типов:
Заключение
В данной статье, мы увидели, как два подхода - наследование и композиция, решают одни и те же задачи на примере Dlang и Rust. Но Dlang предоставляет полный арсенал "классического ОО-инструментария":
- можно строить иерархии классов
- наследовать поля и методы
- использовать базовый класс для общего кода и получать классический полиморфизм из коробки
Rust, напротив, вынуждает мыслить по-другому: раз нет наследования, нужно компоновать объекты из небольших частей и использовать контракты в виде трейтов для определения общего поведения.
Постскриптум:
- при написании статьи использовались следующие версии инструментов:
(1) DMD64 D Compiler v2.111.0
(2) rustc 1.86.0 (05f9846f8 2025-03-31)
(3) cargo 1.86.0 (adf9b6ad1 2025-02-28) - сделаю отсылку на весьма полезную статью про схожую тему: https://stevedonovan.github.io/rust-gentle-intro/object-orientation.html откуда, собственно я взял англоязычные цитаты, рекомендую ее к прочтению
- к сожалению, Дзен перестал поддерживать функционал вставки исходного программного кода с syntax highlighting, на момент написания данной статьи. Про схожие проблемы рассказывают и другие Дзен-авторы: https://dzen.ru/a/XtUGaNtXXh9qY-gb поэтому вместо исходного кода, Вы видите комбинацию из картинки и ссылки на код в GitHub Gist. Прошу прощения за неудобства!