Композиция классов: объект внутри объекта
- Зачем это нужно
- Два вопроса, которые всё решают
- Почему наследование подводит
- Что скажет компилятор, если всё же наследовать
- Делегирование: взять чужое поведение, не наследуя
- Ловушка копирования
- Равенство составных объектов
- Кто создаёт вложенный объект
- Как это выглядит в приложении
- Типичные ошибки
- Куда углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
Один класс вы уже писали. Настоящая программа состоит из десятков классов, и главный вопрос — не «как написать класс», а как их соединить.
Способов ровно два. Либо один класс объявляет себя разновидностью другого — это наследование. Либо один класс держит другой внутри себя как деталь — это композиция. Первое звучит солиднее и изучается раньше, второе применяется чаще и почти всегда оказывается правильным.
Эта глава — про второй способ и про то, почему в современном коде он победил. Наследование будет в следующей главе, и тогда разница станет видна с обеих сторон.
Два вопроса, которые всё решают
Отличить одно от другого можно двумя фразами:
- «является» — машина является транспортом. Это наследование.
- «имеет» — машина имеет двигатель. Это композиция.
Проверка простая: попробуйте сказать про свои классы обе фразы и посмотрите, какая звучит правдой.
class Engine(val power: Int)
class Car(val model: String) {
val engine = Engine(120) // машина ИМЕЕТ двигатель
}
Композиция не имеет ни одного ключевого слова. Наследование заметно (: Engine), интерфейсы заметны (: Speaker), а композиция выглядит как обычное свойство — и именно поэтому её часто не замечают как отдельный приём. Между тем «класс со свойствами-объектами» — это девять из десяти классов в любом приложении.
Почему наследование подводит
Разберём на задаче, которая выглядит как созданная для наследования. Нужна корзина — список товаров с одной добавкой: метод «добавить дважды».
Соблазнительное решение: раз корзина — это список, унаследуемся от списка.
class CartByInheritance : ArrayList<String>() {
fun addTwice(x: String) { add(x); add(x) }
}
Работает. Но посмотрите, что получилось наружу:
// ── подход 1: наследование от готового списка ──
class CartByInheritance : ArrayList<String>() {
fun addTwice(x: String) { add(x); add(x) }
}
// ── подход 2: композиция, список внутри ──
class CartByComposition {
private val items = mutableListOf<String>()
fun add(x: String) { items.add(x) }
fun addTwice(x: String) { add(x); add(x) }
fun total() = items.size
fun list(): List<String> = items.toList()
}
fun main() {
println("── наследование даёт наружу ВСЁ ──")
val c1 = CartByInheritance()
c1.addTwice("Nike")
println(" addTwice: $c1")
c1.clear() // никто не хотел этого метода, но он есть
c1.add(0, "подделка") // и вставки по индексу тоже
println(" после clear() и add(0, …): $c1")
println()
println("── композиция отдаёт только то, что решили ──")
val c2 = CartByComposition()
c2.addTwice("Nike")
println(" addTwice: ${c2.list()}, всего ${c2.total()}")
println(" clear() снаружи недоступен: его просто нет")
}
Настоящий вывод:
── наследование даёт наружу ВСЁ ──
addTwice: [Nike, Nike]
после clear() и add(0, …): [подделка]
── композиция отдаёт только то, что решили ──
addTwice: [Nike, Nike], всего 2
clear() снаружи недоступен: его просто нет
Разница принципиальная. Унаследовавшись, вы отдали наружу весь ArrayList: clear(), вставку по индексу, сортировку, замену элемента — три десятка методов, которых вы не писали и за поведение которых не отвечаете. Любой из них может испортить корзину в обход вашей логики.
При композиции наружу торчит ровно то, что вы объявили: add, addTwice, total, list. Список спрятан за private, и добраться до него нельзя.
Это и есть главный довод в пользу композиции: она позволяет не наследовать лишнее. Наследование — сделка «всё или ничего»: взяв один нужный метод, вы берёте вместе с ним все остальные, включая те, что ломают ваши правила.
Что скажет компилятор, если всё же наследовать
Забегая вперёд: в Kotlin наследование намеренно затруднено. Вот настоящие сообщения компилятора (Kotlin 2.0.21).
Обычный класс унаследовать нельзя:
class Engine(val power: Int)
class TurboEngine : Engine(200)
This type is final, so it cannot be extended.
Все классы в Kotlin закрыты по умолчанию — чтобы открыть, нужно слово open.
Метод переопределить тоже нельзя:
open class Engine(val power: Int) {
fun start() = "поехали"
}
class TurboEngine(power: Int) : Engine(power) {
override fun start() = "с турбо!"
}
'start' in 'Engine' is final and cannot be overridden.
Открытым должен быть не только класс, но и каждый метод отдельно.
А data class не наследуется вовсе:
Function 'fun component1(): Int' generated for the data class conflicts with
the supertype member 'fun component1(): Int' defined in '/Point'.
This type is final, so it cannot be extended.
Обратите внимание, как язык расставил усилия. Композиция не требует ничего. Наследование требует open на классе, open на каждом методе и override на переопределении — три осознанных действия. Это не случайность: авторы Kotlin сделали более опасный приём более трудоёмким.
Делегирование: взять чужое поведение, не наследуя
Иногда наружу всё-таки нужен чужой интерфейс — чтобы объект можно было подставить туда, где ждут этот тип. Наследоваться ради этого не обязательно: в Kotlin есть слово by.
interface Speaker { fun say(): String }
class Russian : Speaker { override fun say() = "привет" }
class Loud(private val inner: Speaker) : Speaker by inner {
override fun say() = inner.say().uppercase() + "!"
}
Живой вывод:
Russian().say() = привет
Loud(Russian()).say() = ПРИВЕТ!
Это композиция, притворяющаяся наследованием: внутри — обычное свойство, а снаружи Loud выглядит как Speaker и подставляется всюду, где ждут Speaker.
by особенно удобен, когда интерфейс большой, а изменить надо один метод. Без делегирования пришлось бы вручную написать все методы, каждый строкой «позвать внутренний объект». Компилятор делает это за вас.
Ловушка копирования
Теперь самое важное и самое неочевидное. Когда внутри объекта лежит другой объект, копирование работает не так, как ожидается.
data class умеет copy() — казалось бы, копия. Посмотрите, что происходит:
data class Address(var city: String, val street: String)
data class Person(val name: String, val address: Address, val tags: MutableList<String>)
fun main() {
val a = Person("Олег", Address("Самара", "Мира"), mutableListOf("студент"))
val b = a.copy(name = "Никита")
println("до правки:")
println(" a = $a")
println(" b = $b")
b.address.city = "Москва"
b.tags.add("наставник")
println("после правки ТОЛЬКО b:")
println(" a = $a")
println(" b = $b")
println(" один и тот же адрес? ${a.address === b.address}")
println(" один и тот же список? ${a.tags === b.tags}")
}
Настоящий вывод:
до правки:
a = Person(name=Олег, address=Address(city=Самара, street=Мира), tags=[студент])
b = Person(name=Никита, address=Address(city=Самара, street=Мира), tags=[студент])
после правки ТОЛЬКО b:
a = Person(name=Олег, address=Address(city=Москва, street=Мира), tags=[студент, наставник])
b = Person(name=Никита, address=Address(city=Москва, street=Мира), tags=[студент, наставник])
один и тот же адрес? true
один и тот же список? true
Правили только b, а изменился и a. Причина: copy() копирует ссылку на вложенный объект, а не сам объект. Обёртки стало две, а адрес внутри остался один на двоих.
В приложении это выглядит так: «изменил один товар — изменились все» или «правка в корзине уехала в историю заказов». Ошибку находят долго, потому что код копирования выглядит правильным.
Лечение простое и надёжное — делать вложенное неизменяемым:
data class Address(val city: String, val street: String) // val вместо var
data class Person(val name: String, val address: Address, val tags: List<String>) // List вместо MutableList
Тогда «изменить» вложенный объект нельзя в принципе — только создать новый, и copy() станет безопасным.
Оператор === в примере, к слову, отвечает на вопрос «это буквально один и тот же объект в памяти?», в отличие от ==, который сравнивает содержимое.
Равенство составных объектов
Раз уж зашла речь про ==, стоит увидеть разницу между data class и обычным классом, когда внутри лежит другой объект.
Живой прогон:
── equals у data class смотрит на содержимое ──
p1 == p2: true
p1 === p2: false
── а у обычного класса — нет ──
o1 == o2: false
p1 и p2 — два разных объекта (=== даёт false), но с одинаковым содержимым, и data class считает их равными. При этом сравнение идёт вглубь: вложенный Address тоже сравнивается по содержимому, потому что он тоже data class.
А обычный класс сравнивается по ссылке: два заказа с одинаковым номером не равны.
Если вложить в data class обычный класс, равенство сломается наполовину: снаружи сравнение по содержимому, а вложенная часть — по ссылке. Два объекта с «одинаковыми» данными окажутся неравными, и причина будет неочевидной. Внутри data class держите либо data class, либо простые типы.
Кто создаёт вложенный объект
Последний важный выбор. Вложенный объект можно создать внутри класса — или принять снаружи через конструктор.
class Basket {
private val items = mutableListOf<String>()
fun add(x: String) { items.add(x) }
fun size() = items.size
override fun toString() = items.toString()
}
// вложенный объект СОЗДАЁТСЯ внутри — жёсткая связь
class OrderOwn(val number: Int) {
val basket = Basket()
}
// вложенный объект ПРИНИМАЕТСЯ снаружи — связь мягкая
class OrderGiven(val number: Int, val basket: Basket)
fun main() {
println("── свой объект: у каждого заказа он свой ──")
val a = OrderOwn(1)
val b = OrderOwn(2)
a.basket.add("Nike")
println(" a.basket=${a.basket} b.basket=${b.basket}")
println()
println("── принятый снаружи: можно случайно поделить один на двоих ──")
val shared = Basket()
val c = OrderGiven(3, shared)
val d = OrderGiven(4, shared)
c.basket.add("Adidas")
println(" c.basket=${c.basket} d.basket=${d.basket}")
println(" это один и тот же объект? ${c.basket === d.basket}")
println()
println("── зато принятый снаружи можно подменить в тесте ──")
val fake = Basket()
repeat(3) { fake.add("товар $it") }
val test = OrderGiven(7, fake)
println(" заказ с заранее наполненной корзиной: ${test.basket.size()} шт. ${test.basket}")
}
Настоящий вывод:
── свой объект: у каждого заказа он свой ──
a.basket=[Nike] b.basket=[]
── принятый снаружи: можно случайно поделить один на двоих ──
c.basket=[Adidas] d.basket=[Adidas]
это один и тот же объект? true
── зато принятый снаружи можно подменить в тесте ──
заказ с заранее наполненной корзиной: 3 шт. [товар 0, товар 1, товар 2]
У обоих способов есть цена:
- Создаёт внутри. У каждого заказа гарантированно своя корзина, поделить невозможно. Но подменить её снаружи тоже нельзя — а значит, в тесте не подсунуть заранее наполненную.
- Принимает снаружи. Подменяется легко, тесты пишутся просто. Но за «каждому своё» теперь отвечает тот, кто создаёт: передали одну корзину двум заказам — и они делят её, не подозревая об этом.
Второй способ — это первый шаг к тому, что позже назовут внедрением зависимостей: объект не добывает себе части сам, а получает готовыми. На этом стоит вся архитектура мобильного приложения: ViewModel не создаёт репозиторий внутри, а принимает его — иначе подменить сеть на фейк в тесте было бы нечем.
Как это выглядит в приложении
Всё, что разобрано выше, — это устройство любого экрана в Android:
class CartViewModel(
private val repository: CartRepository, // принят снаружи
) {
private val calculator = PriceCalculator() // создан внутри
}
Репозиторий принимается снаружи, потому что в тесте его подменяют на фейковый. Калькулятор цен создаётся внутри, потому что он ничего не знает о внешнем мире и подменять его незачем.
Обратите внимание: CartViewModel ни от чего не наследуется в этом примере, и это типично. В настоящем Android он унаследуется от ViewModel — ровно один раз и ровно потому, что этого требует система. Всё остальное соединяется композицией.
Правило, которое стоит запомнить: наследуйтесь только тогда, когда этого требует чужая библиотека. Между своими классами — композиция.
Типичные ошибки
1. Наследование ради переиспользования кода. «В ArrayList уже есть add, унаследуюсь» — и наружу уезжают три десятка чужих методов.
2. Наследование, когда «имеет», а не «является». class Car : Engine() компилируется и даже работает, но машина не является двигателем, и на третьем классе иерархия рассыплется.
3. copy() в расчёте на глубокую копию. Вложенный изменяемый объект остаётся общим, и правка копии задевает оригинал.
4. var и MutableList внутри data class. Именно они делают предыдущую ошибку возможной.
5. Обычный класс внутри data class. Равенство ломается наполовину: снаружи по содержимому, внутри по ссылке.
6. Вложенный объект без private. Если отдать его наружу как val, снаружи смогут менять его напрямую — и вся защита, ради которой затевалась композиция, исчезает.
7. Один объект передан нескольким владельцам. Компилятор промолчит, а состояние поедет между ними.
8. Всё принимается снаружи. Обратный перекос: конструктор на десять параметров, где половина — мелочи, которые класс мог создать сам.
Куда углубиться
- Делегированные свойства:
by lazy,by observable— тот жеby, но для свойств, а не интерфейсов. - Внедрение зависимостей и библиотеки Hilt и Koin: кто и как собирает граф объектов за вас.
- Глубокое копирование: почему в Kotlin нет
deepCopy()и как его пишут руками. - Принцип подстановки Лисков — формальное объяснение, почему «является» иногда врёт.
- Sealed-иерархии как замена наследованию для представления вариантов.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Kotlin — классы и свойства — конструкторы, свойства, инициализация.
- Kotlin — делегирование — как работает
byи что генерирует компилятор. - Kotlin — data-классы — что именно порождают
copy,equalsиcomponentN. - Kotlin — равенство — разница между
==и===.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.