data class предназначен для хранения значений: по свойствам primary-конструктора компилятор генерирует equals()/hashCode(), toString(), copy() и componentN() для деструктуризации.
Учитывается только primary-конструктор: свойство, объявленное в теле класса, игнорируют equals, hashCode, toString и copy.
Требования: минимум один параметр primary-конструктора, и каждый объявлен как val или var.
Наследование: data class может наследовать класс и реализовывать интерфейсы, но не может быть open, abstract, sealed или inner, поэтому от него самого наследоваться нельзя.
**Свойства var**: разрешены, но опасны: изменение поля меняет hashCode, и объект, уже лежащий в HashSet или используемый как ключ HashMap, больше не находится. Лучше val плюс copy().
**copy() поверхностный: вложенные объекты и списки общие у оригинала и копии.
data object** (Kotlin 1.9): синглтон с читаемым toString(), обычно data object Loading внутри sealed-состояния.
AndroidJunior
Kotlin: ООП и функциональщина
Классы, data class, sealed class, object, extension-функции, scope-функции
Закрыто вопросов
0 / 25
- 0Повторить
- 9Часто спрашивают
Вопросы
У sealed-класса или sealed-интерфейса закрытый набор прямых подтипов, известный на этапе компиляции, поэтому when по нему покрывает все случаи без else, а компилятор подсвечивает каждый такой when сразу, как только вы добавляете новый подтип.
Где могут лежать подтипы: прямые подтипы должны быть в том же модуле и в том же пакете; sealed-класс абстрактный, а его конструкторы protected или private.
Каждый подтип является полноценным классом: он может нести свои данные (data class Error(val message: String)) или быть синглтоном (data object Loading). В этом отличие от enum, у которого все константы одной формы.
Sealed interface или sealed class: интерфейс позволяет типу входить в несколько sealed-иерархий и реализовать его через enum или object; класс позволяет подтипам разделять состояние через конструктор.
Типичное применение: состояние экрана (Loading/Content/Error), результат операции, одноразовые UI-события, токены парсера.
Не пишите ветку else для sealed-типа: она молча проглотит подтип, который добавят позже, то есть ровно ту ошибку, которую вы хотели получить от компилятора.
enum class задаёт фиксированный набор именованных экземпляров одного типа; у констант могут быть свойства из конструктора, методы и даже собственные переопределения, но форма у всех одна и каждая существует ровно в одном экземпляре.
Данные и поведение: enum class Plan(val priceCents: Int) { FREE(0), PRO(999) }; абстрактный метод каждая константа может реализовать по-своему.
Встроенные члены: name, ordinal, valueOf(String) и entries (Kotlin 1.9), который заменил values(): entries возвращает закэшированный read-only список, а values() копирует массив при каждом вызове.
**valueOf бросает** IllegalArgumentException на неизвестном имени. Для значений с сервера нужен поиск с фолбэком (entries.firstOrNull { it.name == raw } ?: UNKNOWN), иначе новое значение на бэкенде роняет все старые версии приложения.
Берите sealed-тип, когда случаям нужны разные данные (Error(message) рядом с Loading), когда у случая должно быть несколько экземпляров или когда неизвестное значение должно сохранить исходную строку для логов (Unknown(raw)).
Правило: enum подходит для закрытого списка констант одной формы, а sealed-иерархия нужна для закрытого списка разных форм.
Абстрактный класс может хранить состояние и имеет конструктор, но унаследовать можно только один; интерфейс состояния не хранит, зато класс может реализовать сколько угодно интерфейсов, и в обоих могут быть реализованные методы.
Final по умолчанию: обычный class и его члены нельзя наследовать и переопределять. Разрешение даёт open, а каждое переопределение требует ключевого слова override. Так снимается проблема хрупкого базового класса: никто не наследуется от кода, который для этого не проектировали.
**open и public**: public определяет, кто может видеть и вызывать объявление, а open определяет, кто может наследовать или переопределять. Это независимые вещи.
**abstract class**: экземпляр создать нельзя; может иметь конструктор, поля с backing field и блоки init, сочетает абстрактные и конкретные члены. abstract подразумевает open.
**interface: абстрактные члены, реализации методов по умолчанию и свойства без backing field (абстрактные или с геттером). Конструктора нет.
Одинаковый метод по умолчанию в двух интерфейсах**: класс обязан его переопределить и выбрать реализацию через super<A>.greet().
Одно двоеточие заменяет и extends, и implements: class Service : BaseRepo(url), Logger. Интерфейс нужен для контракта или возможности, а абстрактный класс подходит, когда подтипы действительно делят состояние и инициализацию.
Четыре принципа: инкапсуляция, абстракция, наследование и полиморфизм; перегрузка (overloading) даёт одно имя с разными параметрами и выбирается при компиляции, а переопределение (overriding) заменяет метод в подклассе и выбирается во время выполнения по реальному типу.
Инкапсуляция: прятать состояние за поведением: private set, приватные поля, наружу StateFlow вместо MutableStateFlow.
Абстракция: зависеть от того, что объект делает, а не как: интерфейс UserRepository вместо прямой работы с Retrofit и Room.
Наследование: переиспользовать и уточнять базовый тип; в Kotlin только от open или abstract класса.
Полиморфизм: один вызов, разное поведение: shape.area() выполняет версию Circle или Square в зависимости от объекта.
Перегрузка: fun show(id: Long) и fun show(name: String), выбор по объявленным типам аргументов. В Kotlin большинство перегрузок заменяют аргументы по умолчанию; @JvmOverloads генерирует перегрузки для вызова из Java.
Переопределение: override fun area() в подклассе, только для open или абстрактного члена; нужную версию выбирает динамическая диспетчеризация во время выполнения.
В Kotlin есть public (по умолчанию), private, protected и internal; internal означает видимость везде внутри одного модуля, то есть набора файлов, которые компилируются вместе, например source set main одного Gradle-модуля.
**public: по умолчанию. Уровня package-private, как в Java, нет.
private: внутри класса виден только в этом классе; на верхнем уровне виден только в этом файле.
protected: класс и его наследники. В отличие от Java, не весь пакет.
internal: весь модуль. В многомодульном Android-приложении он прячет детали реализации (DTO, мапперы, DAO) от других feature-модулей, при этом тесты самого модуля их видят.
Во что компилируется**: в JVM нет internal, поэтому такой класс в байткоде public, а internal-члены получают искажённые имена вроде load$data_release. Код на Kotlin до них не дотянется, а код на Java дотянется.
Модификаторы применимы и к конструкторам: class Config private constructor(...) вместе с фабрикой в companion контролирует, как создаются экземпляры.
Расширение добавляет функцию или свойство существующему типу, не меняя его исходник; оно компилируется в статический метод, который получает receiver первым параметром, поэтому видит только public- и internal-члены receiver, но не private.
Синтаксис: fun String.initials(): String; внутри this обозначает receiver.
Свойства-расширения не имеют backing field: только геттер (и сеттер для var), вычисляемый из receiver.
На JVM: расширение верхнего уровня из StringExt.kt превращается в StringExtKt.initials(String); так его и вызывают из Java.
Разрешаются статически: расширение выбирается по объявленному типу переменной, а не по типу во время выполнения, и член класса с той же сигнатурой всегда побеждает.
Nullable receiver: fun String?.orDash() можно вызвать на null и обработать его внутри.
Используйте их для хелперов к типам, которые вам не принадлежат (Context.dp(), оператор для Flow), и чтобы классы оставались небольшими; не прячьте в расширение важное поведение там, где его никто не станет искать.
infix-функция представляет собой член класса или расширение ровно с одним параметром, которую можно вызывать без точки и скобок: a to b вместо a.to(b).
Правила: помечена infix, является членом или расширением, имеет ровно один параметр, и этот параметр не может быть vararg или иметь значение по умолчанию.
В стандартной библиотеке: to (создаёт Pair), until, downTo, step, and/or/xor у Boolean и shl/shr у Int.
Приоритет: infix-вызов связывает слабее арифметики, но сильнее сравнений: 1 + 2 to 3 означает (1 + 2) to 3.
Когда писать: в DSL и тестовых проверках (result shouldBe 5), где вызов читается как фраза. Для обычного API обычный вызов понятнее.
Все они выполняют блок над объектом и различаются двумя вещами: доступен ли объект внутри блока как this или как it и что возвращает вызов (сам объект или результат блока).
let использует it и возвращает результат блока. Null-безопасные преобразования: user?.let { show(it) }.
run использует this и возвращает результат блока. Вычислить что-то из членов объекта.
with(obj) использует this и возвращает результат блока. То же, что run, но обычная функция, для non-null объекта.
apply использует this и возвращает объект. Настроить объект сразу после создания.
also использует it и возвращает объект. Побочные эффекты внутри цепочки: логирование, валидация.
Правило: «верни мне объект» означает apply/also, «верни результат» означает let/run/with. Не вкладывайте их друг в друга: уже на двух уровнях it/this непонятно, с каким объектом работает строка. takeIf/takeUnless дополняют семейство и возвращают объект или null.
Равные объекты обязаны возвращать одинаковый hashCode; если переопределить только equals, HashSet или HashMap обычно ищет не в той корзине и не находит объект, равный уже сохранённому.
Поведение по умолчанию: Any.equals сравнивает ссылки, поэтому два отдельно созданных объекта с одинаковыми полями не равны, пока вы его не переопределите (data class делает это за вас).
Правило работает в одну сторону: у равных объектов хэш-коды обязаны совпадать; у неравных могут совпасть.
Выбор полей: сущность сравнивается только по id, а value-тип по всем полям. Берите стабильные поля: если поле из hashCode меняется, пока объект лежит в HashSet, contains вернёт для него false.
Симметричность: для final-класса достаточно other is Point; для open-класса сравнивайте javaClass, чтобы a == b и b == a совпадали и для подклассов.
**== и ===**: == вызывает equals и безопасен к null; === сравнивает ссылки.
Это важно не только для тестов: на equals опираются distinct(), ключи map, DiffUtil, пропуск рекомпозиции в Compose и StateFlow, который отбрасывает значение, равное текущему.
Вложенный класс не хранит ссылку на внешний экземпляр (это статический вложенный класс в терминах Java), а inner-класс хранит скрытую ссылку на него: может обращаться к членам внешнего класса, но и держит внешний объект в памяти.
Nested: class Outer { class Nested }, создаётся как Outer.Nested(), к внешнему экземпляру доступа нет. В Kotlin это вариант по умолчанию; в Java по умолчанию используется inner.
**inner**: создаётся как outer.Inner(), видит члены внешнего класса, а this@Outer обозначает внешний экземпляр.
Утечка: отдайте inner-экземпляр из Fragment чему-то долгоживущему (синглтону-шине событий, Handler с отложенными сообщениями, фоновому потоку), и Fragment вместе со всем деревом View останется в памяти. LeakCanary покажет его как retained Fragment.
Тот же захват без ключевого слова: анонимные объекты, лямбды, обращающиеся к членам класса, и связанные ссылки вроде ::onItem тоже держат внешний экземпляр.
Фреймворкам нужен nested: сущности Room, сериализуемые DTO и Parcelable-классы не могут быть inner, как и data class.
Решение: оставьте класс вложенным, передайте ему ровно то, что нужно (колбэк, StateFlow), и отписывайте слушателей в парном колбэке жизненного цикла.
Код выведет [1, 2, 3, 4, 5], а затем 4 true 5: apply и also возвращают receiver, а let и run возвращают последнее выражение блока.
a: apply возвращает сам list, а не копию. К моменту println(a) последующие вызовы уже добавили 4 и 5, поэтому печатаются все пять чисел.
b: also возвращает список после добавления 4, и .size равен 4.
c: let возвращает последнее выражение блока, а MutableList.add возвращает Boolean, поэтому в c оказывается true, а не список.
d: run возвращает size, который после всех добавлений равен 5.
Строка с c содержит баг, который реально попадает в прод: let вместо задуманного also даёт Boolean или Unit вместо объекта, а вывод типов скрывает это до первого использования значения.
class служит шаблоном, по которому создаётся сколько угодно экземпляров, object задаёт синглтон, который объявляется и создаётся за один шаг, а companion object описывает синглтон, привязанный к классу, в котором живёт то, что в Java было бы static.
**object на JVM**: компилируется в final-класс с private-конструктором и полем static final INSTANCE, которое заполняется в статическом инициализаторе. JVM выполняет его один раз, при первом обращении, под блокировкой инициализации класса, поэтому создание потокобезопасно без double-checked locking.
Потокобезопасно только создание: var или MutableMap внутри объекта образуют общее изменяемое состояние, и ему нужна своя синхронизация.
Время жизни: экземпляр держит статическое поле, поэтому сборщик мусора не соберёт его, пока жив процесс приложения. Context, Activity или View, сохранённые в object, приводят к утечке.
**companion object**: компилируется во вложенный класс Companion плюс статическое поле во внешнем классе и инициализируется при загрузке внешнего класса. Его члены не являются настоящими статическими членами JVM: для вызова из Java нужны @JvmStatic или @JvmField, а const val становится статической константой.
Object expression: object : TextWatcher { ... } каждый раз создаёт новый анонимный экземпляр; это котлиновский аналог анонимного класса Java.
Companion object инициализируется один раз, при первой загрузке класса; для каждого экземпляра сначала строится суперкласс, затем инициализаторы свойств и блоки init выполняются в порядке их появления в теле класса, а тело вторичного конструктора выполняется последним.
Companion object (статический инициализатор), один раз на загрузку класса.
Вторичный конструктор сначала делегирует через this(...) первичному (сам вызывать super(...) он может только в классе без первичного конструктора).
Первичный конструктор вызывает конструктор суперкласса, который полностью инициализируется первым.
Связываются параметры первичного конструктора; инициализаторы свойств и блоки init выполняются сверху вниз, поэтому init не может прочитать свойство, объявленное ниже.
Выполняется тело вторичного конструктора, уже на полностью собранном объекте.
Ловушка, на которую давят на собеседованиях: вызов open-метода из init базового класса запускает переопределение подкласса до того, как его свойства инициализированы (они ещё null или 0). Не вызывайте переопределяемые члены во время конструирования.
Код выведет [tea, milk], false, true: copy() поверхностный, поэтому обе корзины ссылаются на один и тот же MutableList, и добавление в список копии меняет оригинал.
**copy() копирует ссылки: он вызывает конструктор с теми же значениями свойств; список, массив или любой другой изменяемый объект внутри становится общим, а не клонируется.
original == copy даёт false**: только потому, что отличается owner. При одинаковом владельце было бы true, ведь equals сравнивает общий список по содержимому.
**=== для списков даёт true: это буквально один объект.
Исправление**: делайте data-классы глубоко неизменяемыми: val items: List<String> и cart.copy(items = cart.items + "milk"), что создаёт новый список. Если изменяемое поле неизбежно, копируйте его явно: copy(items = items.toMutableList()).
Особенно это важно для UI-состояния: если изменить общий список внутри значения StateFlow, «новое» состояние окажется равно старому, и экран не обновится.
Код выведет Circle, shape, Circle: члены класса диспетчеризуются по типу во время выполнения, расширения выбираются по объявленному типу, а член класса всегда побеждает расширение с той же сигнатурой.
s.name(): name является open-членом, поэтому виртуальный вызов попадает в Circle.name(), хотя s объявлена как Shape.
s.label(): расширения представляют собой статические функции, выбранные при компиляции по объявленному типу Shape, поэтому выполняется Shape.label(); то, что во время выполнения там Circle, роли не играет.
Circle().name() (член и расширение с одинаковой сигнатурой): побеждает член, а компилятор предупреждает, что расширение затенено, так что fun Circle.name() так вызвать невозможно.
Следствия: расширение нельзя переопределить, оно не может изменить то, что класс уже делает, и не подходит там, где нужен полиморфизм; тогда нужен open-член или метод интерфейса.
progress объявлен как var в конструкторе data class, поэтому он входит в hashCode; после того как onProgress его меняет, remove(d) ищет по другому хешу, ничего не находит, и каждая завершённая загрузка навсегда остаётся в active.
Почему так происходит: HashSet кладёт элемент в корзину по хешу на момент добавления; remove пересчитывает хеш по текущим полям и ищет не в той корзине, поэтому не находится даже та же самая ссылка.
Исправление: перенести progress в тело класса. Сгенерированные equals, hashCode, toString и copy используют только свойства primary-конструктора, так что идентичность теперь определяет url, который не меняется.
Альтернативы: сделать Download полностью неизменяемым и хранить HashMap<String, Download> с ключом url, заменяя значение через copy(progress = …); или взять обычный класс с equals/hashCode по стабильному id.
Как поймать в следующий раз: юнит-тест, который вызывает onProgress, а затем finish, сразу это покажет.
Итератор списка запоминает счётчик изменений в начале цикла и проверяет его при каждом next(); удаление через сам список меняет этот счётчик, и следующий шаг цикла, как правило, бросает исключение.
**downloads.removeAll { it.done }**: прямо выражает намерение и удаляет за один проход без вашего открытого цикла; retainAll { } выполняет обратную операцию.
**filterNot { it.done }: когда нужен новый read-only список, например для UI-состояния.
Явный итератор с iterator.remove(): когда цикл должен делать что-то кроме удаления.
Итерация по копии**: for (d in downloads.toList()), когда тело вызывает код, который может изменить список.
**forEach { } работает как тот же цикл**: падает так же, как и изменение map во время обхода её keys или entries.
Тихий вариант: удаление предпоследнего элемента не бросает исключение; цикл просто заканчивается, и последний элемент не обрабатывается. Название тоже сбивает с толку: обычно исключение означает, что один поток противоречит сам себе; при настоящем многопоточном доступе нужна изоляция в одном потоке или конкурентная коллекция, а не другой цикл.
:: превращает существующую функцию, свойство или конструктор в функциональное значение; связанная ссылка (obj::method) захватывает объект в момент создания ссылки, а несвязанная (Type::method) принимает receiver первым параметром.
::save: функция верхнего уровня, тип (Order) -> Unit.
viewModel::onSubmit: связанная, тип (String) -> Unit; receiver зафиксирован прямо сейчас.
User::name: несвязанная ссылка на свойство, (User) -> String; поэтому компилируются map(User::name) и sortedBy(User::age).
::UserRow служит ссылкой на конструктор: users.map(::UserRow).
Начиная с Kotlin 1.4 ссылка подстраивается под ожидаемый тип: параметры со значениями по умолчанию можно опустить, а результат привести к Unit.
Ловушка
Связанный receiver захватывается, а не перечитывается. val label = current::name продолжает возвращать имя первого пользователя после переприсваивания current и держит этот объект в памяти. Внутри класса ::onItem захватывает this: та же утечка, что и с inner-классом, если ссылка живёт дольше Fragment.
Берите ссылку, когда лямбда лишь пробрасывала бы аргументы, а лямбду берите, как только аргументы нужно переставить, отбросить или добавить.
Каждый оператор соответствует функции с фиксированным именем, и достаточно пометить её operator: a + b вызывает a.plus(b), a[i] вызывает a.get(i), x in a вызывает a.contains(x).
Арифметика: plus, minus, times, div, rem, unaryMinus.
Индексация: a[i] вызывает get, a[i] = v вызывает set, с любым числом индексов.
Остальное: a(x) вызывает invoke, a..b вызывает rangeTo, a < b вызывает compareTo, для for (x in a) нужен iterator, для деструктуризации нужен componentN.
Составное присваивание: a += b использует plusAssign, если он есть, иначе a = a.plus(b); если для var доступны оба, это ошибка неоднозначности.
Правила: имена фиксированы, новые операторы придумать и приоритет изменить нельзя, а оператор можно объявить расширением для чужого типа.
Операторы уместны для типов с настоящей арифметикой или семантикой коллекции (деньги, векторы, длительности, сетки) и никогда не должны прятать побочные эффекты вроде +, который пишет в базу данных.
Sequence прогоняет каждый элемент через всю цепочку лениво, только когда запускается терминальная операция вроде toList() или first(), а цепочка на List выполняет каждый шаг для всех элементов и создаёт промежуточный список на каждом шаге.
Ранний выход: first { }, take(n) и generateSequence останавливаются, как только ответ известен; бесконечный источник возможен только у последовательности.
Маленькие списки: здесь asSequence() вредит: каждый шаг создаёт объект-обёртку, а каждый элемент проходит через объект-лямбду, и это дороже пары крошечных промежуточных списков.
Операции с состоянием: sorted(), distinct() и groupBy() требуют весь вход, поэтому с этого места лень пропадает.
Нет доступа по индексу: get(i) и size недоступны, не потребив последовательность.
Это не Java Stream: параллелизма нет; последовательность обычно можно обойти повторно, но построенная из одноразового источника бросает исключение на втором проходе.
SAM-конверсия позволяет передать лямбду туда, где ожидается интерфейс с единственным абстрактным методом; у Java-интерфейсов она работает автоматически, а Kotlin-интерфейсу нужен модификатор fun interface.
Сторона Java: button.setOnClickListener { } работает, потому что у OnClickListener один абстрактный метод.
Сторона Kotlin: fun interface Validator { fun validate(s: String): Boolean } используется как Validator { it.isNotBlank() }; обычный interface лямбду не принимает.
**В сравнении с функциональным типом или typealias**: fun interface является именованным типом, удобнее для вызова из Java, может иметь методы по умолчанию и должен иметь ровно один абстрактный член; функциональный тип (String) -> Boolean легче, но анонимен.
Ловушка идентичности: каждая лямбда даёт новый объект, поэтому removeListener { } со свежей лямбдой ничего не удалит; держите экземпляр в переменной.
Наследование привязывает подкласс к реализации родителя, и изменение базового класса может сломать подклассы; композиция держит зависимость за интерфейсом, а class LoggingRepo(inner: Repo) : Repo by inner заставляет компилятор написать за вас все пробрасывающие методы.
Чем плохо наследование
Хрупкий базовый класс: подкласс зависит от того, как родитель вызывает собственные методы, а не только от его обещаний.
Только один родитель: нельзя сочетать два поведения, унаследовав два класса.
Наследуется всё: включая состояние и методы, которые подклассу не нужны.
Чем помогает by
: Repo by inner генерирует каждый метод Repo как вызов inner; методы, написанные вами, имеют приоритет, поэтому переопределяется только то, что меняется. Это декоратор в одну строку: логирование, кэширование, повторы, тестовый фейк, у которого падает один вызов.
Оборачиваемый класс не обязан быть open.
Ограничение: inner не знает, что его обернули. Если его refresh() вызывает собственный load(), ваш переопределённый load() не используется: обратного вызова в обёртку нет.
Наследование по-прежнему уместно для настоящего отношения «является» с базой, спроектированной для расширения, например ViewModel или sealed-иерархия. Делегирование свойств (by lazy) представляет собой отдельную возможность с тем же ключевым словом.
SOLID объединяет пять правил для классов, которые остаются лёгкими для изменений: единственная ответственность, открытость/закрытость, подстановка Лисков, разделение интерфейсов и инверсия зависимостей.
S (единственная ответственность): одна причина для изменения. Нарушение: ViewModel, который вызывает Retrofit, парсит JSON, пишет в Room и форматирует даты. Решение: перенести работу с данными в репозиторий, а форматирование в маппер.
O (открытость/закрытость): новое поведение добавляется новым кодом, а не правкой работающего. Новый способ оплаты становится новой реализацией PaymentMethod, а не ещё одной веткой в разрастающемся when (type).
L (подстановка Лисков): подтип должен работать везде, где ожидается родитель. Нарушение: ReadOnlyCache : Cache, у которого put() бросает UnsupportedOperationException. Разделение List и MutableList в Kotlin как раз этого избегает.
I (разделение интерфейсов): маленькие, сфокусированные интерфейсы. TextWatcher заставляет переопределить три метода, когда нужен один; doAfterTextChanged { } из core-ktx даёт разделённый вариант.
D (инверсия зависимостей): высокоуровневый код зависит от абстракций. LoginViewModel принимает интерфейс AuthRepository; Hilt подставляет настоящую реализацию, тесты передают фейк.
Нюанс, который стоит упомянуть: sealed-тип с when закрыт для новых типов, но открыт для новых операций (обратный компромисс по сравнению с интерфейсом), и это нормально, когда набор случаев действительно фиксирован.
Singleton сам по себе не антипаттерн, но глобальный object, к которому код обращается напрямую, создаёт скрытое глобальное состояние: вызывающий код его не объявляет, в тестах его не подменить, и живёт он весь процесс.
За что критикуют: глобальный изменяемый object прячет зависимость, его нельзя заменить фейком в тесте, и он удерживает любой Context, Activity или View до смерти процесса.
Один экземпляр, но под управлением DI: внедряйте интерфейс со скоупом @Singleton (Hilt) вместо вызова MySingleton.doWork() отовсюду; тесты подставляют фейк.
**Храните только applicationContext**: не Activity и не View, которые внутри синглтона переживают свой экран.
**object потокобезопасен только при создании**: JVM инициализирует его один раз под блокировкой инициализации класса, но var или изменяемая коллекция внутри всё равно требуют своей синхронизации.
Другие паттерны: Strategy и Decorator (ContextWrapper, делегирование через by) разобраны в списке паттернов Android SDK в теме про архитектуру.