Перейти к основному содержимому
Мобилка07ГЛАВА 07Композиция классовclass OrderBasketCustomerPriceCalculator«имеет», а не «является»объект внутри объекта

Композиция классов: объект внутри объекта

Прочитано0%
Квизы0/5
Уровень 1 · Новичок

Зачем это нужно​

Один класс вы уже писали. Настоящая программа состоит из десятков классов, и главный вопрос — не «как написать класс», а как их соединить.

Способов ровно два. Либо один класс объявляет себя разновидностью другого — это наследование. Либо один класс держит другой внутри себя как деталь — это композиция. Первое звучит солиднее и изучается раньше, второе применяется чаще и почти всегда оказывается правильным.

Эта глава — про второй способ и про то, почему в современном коде он победил. Наследование будет в следующей главе, и тогда разница станет видна с обеих сторон.

Два вопроса, которые всё решают​

Отличить одно от другого можно двумя фразами:

  • «является» — машина является транспортом. Это наследование.
  • «имеет» — машина имеет двигатель. Это композиция.

Проверка простая: попробуйте сказать про свои классы обе фразы и посмотрите, какая звучит правдой.

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() снаружи недоступен: его просто нет
ЧТО ВИДНО СНАРУЖИнаследование от ArrayListaddTwice()add() · remove() · clear()add(0, …) · set() · sort()…и ещё три десяткакорзину можно очистить мимо вашего кодакомпозиция: список внутриaddTwice()add() · total() · list()и больше ничегонаружу отдано ровно то, что решилинаследование — это «я такой же»; композиция — «у меня внутри есть»
Наследование открывает наружу всё, композиция — только выбранное

Разница принципиальная. Унаследовавшись, вы отдали наружу весь ArrayList: clear(), вставку по индексу, сортировку, замену элемента — три десятка методов, которых вы не писали и за поведение которых не отвечаете. Любой из них может испортить корзину в обход вашей логики.

При композиции наружу торчит ровно то, что вы объявили: add, addTwice, total, list. Список спрятан за private, и добраться до него нельзя.

Важно

Это и есть главный довод в пользу композиции: она позволяет не наследовать лишнее. Наследование — сделка «всё или ничего»: взяв один нужный метод, вы берёте вместе с ним все остальные, включая те, что ломают ваши правила.

Отвечено вопросов: 0 из 3
Какой фразой проверяют, что нужна композиция, а не наследование?
Чем плохо унаследовать корзину от ArrayList?
Каким ключевым словом объявляется композиция?
Отвечено верно: 0 из 3

Что скажет компилятор, если всё же наследовать​

Забегая вперёд: в 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
copy() КОПИРУЕТ ССЫЛКУ, А НЕ СОДЕРЖИМОЕa: Personname = «Олег»b = a.copy(name = «Никита»)name = «Никита»ОДИН Addresscity = «Москва»b.address.city = «Москва»изменились ОБАa.address === b.addresstrueправка «копии» задела оригинал — в приложении это «изменил один товар, изменились все»лечится неизменяемостью вложенного: val вместо var, List вместо MutableList
copy() делает новую обёртку, но вложенный объект остаётся общим

Правили только 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() станет безопасным.

Оператор === в примере, к слову, отвечает на вопрос «это буквально один и тот же объект в памяти?», в отличие от ==, который сравнивает содержимое.

Отвечено вопросов: 0 из 3
a.copy() создал b, потом изменили b.address.city. Что стало с a?
Как надёжно защититься от этой ловушки?
Чем === отличается от ==?
Отвечено верно: 0 из 3

Равенство составных объектов​

Раз уж зашла речь про ==, стоит увидеть разницу между 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, либо простые типы.

Отвечено вопросов: 0 из 3
Два объекта обычного класса с одинаковыми полями. Что даст ==?
Внутрь data class положили обычный класс. Что станет с равенством?
Почему компилятор требует open и на классе, и на каждом методе?
Отвечено верно: 0 из 3

Кто создаёт вложенный объект​

Последний важный выбор. Вложенный объект можно создать внутри класса — или принять снаружи через конструктор.

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]
КТО СОЗДАЁТ ВЛОЖЕННЫЙ ОБЪЕКТсоздаёт внутриval basket = Basket()+ у каждого свой, поделить нельзя− снаружи не подменить− в тесте не подсунуть заготовкупринимает снаружиclass Order(val basket: Basket)+ подменяется в тесте− можно случайно отдать один на двоихc.basket === d.basket → trueвторой способ — начало внедрения зависимостей: объект не добывает себе части самно тогда за «каждому своё» отвечает тот, кто создаёт — а не сам класс
Создать внутри или принять снаружи — у каждого способа своя цена

У обоих способов есть цена:

  • Создаёт внутри. У каждого заказа гарантированно своя корзина, поделить невозможно. Но подменить её снаружи тоже нельзя — а значит, в тесте не подсунуть заранее наполненную.
  • Принимает снаружи. Подменяется легко, тесты пишутся просто. Но за «каждому своё» теперь отвечает тот, кто создаёт: передали одну корзину двум заказам — и они делят её, не подозревая об этом.
Совет

Второй способ — это первый шаг к тому, что позже назовут внедрением зависимостей: объект не добывает себе части сам, а получает готовыми. На этом стоит вся архитектура мобильного приложения: ViewModel не создаёт репозиторий внутри, а принимает его — иначе подменить сеть на фейк в тесте было бы нечем.

Как это выглядит в приложении​

Всё, что разобрано выше, — это устройство любого экрана в Android:

class CartViewModel(
private val repository: CartRepository, // принят снаружи
) {
private val calculator = PriceCalculator() // создан внутри
}

Репозиторий принимается снаружи, потому что в тесте его подменяют на фейковый. Калькулятор цен создаётся внутри, потому что он ничего не знает о внешнем мире и подменять его незачем.

Интересный факт

Обратите внимание: CartViewModel ни от чего не наследуется в этом примере, и это типично. В настоящем Android он унаследуется от ViewModel — ровно один раз и ровно потому, что этого требует система. Всё остальное соединяется композицией.

Правило, которое стоит запомнить: наследуйтесь только тогда, когда этого требует чужая библиотека. Между своими классами — композиция.

Отвечено вопросов: 0 из 3
Почему репозиторий во ViewModel принимают снаружи, а не создают внутри?
Две переменные получили одну и ту же корзину через конструктор. Что будет при добавлении товара в одну?
Когда наследование всё-таки оправдано?
Отвечено верно: 0 из 3

Типичные ошибки​

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 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоComposition over inheritance | Delegation design pattern | by keyword | Part 2: KotlindeCoded Programming
ВидеоKotlin - The 'by' keyword in 3 minutesKonstantinos Reppas
ВидеоComposition Vs Inheritance (Seniors Must Know This)Monsterlessons Academy
ВидеоKotlin Interfaces & Class Delegation | Kotlin Tutorial 2019Cody Engel
ВидеоУрок 10: Функции (методы), тип возвращаемого значения, return // Котлин курс с нуляKotlin & Android с нуля – Иван Ветров

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.