Абстракції (Спадкування)
Завдання
Ускладнимо завдання: у нас є притулок з домашніми тваринами, і нам потрібно зберігати уніфіковану інформацію про кожну тварину. Якщо ви раніше створювали таблиці в тому ж Excel, ви, швидше за все, розумієте, як ви можете виділити параметри кожного вихованця.
Що ж таке спадкування? Спадкування - це властивість об'єкта набувати риси іншого об'єкта за допомогою абстракції.
Як же вирішуватимемо завдання? Давайте спочатку виділимо, які вихованці у нас взагалі є:
- собаки
- коти
- папуги
Тепер нашим завданням є знайти загальні властивості даних об'єктів (вихованців) щоб виділити їх у більш загальну сутність. Перше, що швидше за все, спало на думку — це імена. У всіх домашніх тварин є якісь імена. Відразу ж після цього, буде неважко додумати деякі інші властивості: скільки їм років, їх опис (може, наприклад, це говорящий папуга або кіт) та інші їх атрибути (властивості).
Також, кожен з них, має власні особливості, які існують тільки в них – собаки бігають, папуги літають, а коти просто ліниві. Візуалізуймо:
Зі структурою розібрались, але як це реалізувати на Kotlin?
Interface
Одним з варіантів рішення є interface
. Цей тип опису структури припускає тільки опис контракту того, як
клас, що його наслідує (у відношені інтерфейсів ще часто говорять «імплеменує»), буде поводитись та які саме дані
буде мати.
Контракт – формальний опис того, що робить будь-яка сутність (починаючи з функцій до класу або інтерфесу).
Створюється інтерфейс наступним чином:
interface Foo {...}
Варто враховувати, що на відміну від класів чи об'єктів — інтерфейси stateless (тобто, не можуть зберігати ніяких даних). Також вони не є самостійною структурною одиницею й існують тільки за допомогою об'єктів, що їх реалізують (імплементують, наслідують). Тобто, ви не можете зробити наступне:
interface Foo {
val name: String = "" // Error: Property initializers are not allowed in interfaces
}
Тому зробити можна тільки так:
interface Foo {
val name: String
}
Спробуймо віднаслідувати даний інтерфейс:
class Bar : Foo {
override val name: String = "Bar"
}
Ключове слово override
використовується для того, щоб ініціалізовувати те, що не було ініціалізовано до цього або
для того, щоб змінити те, що вже ініціалізовано, якщо можливо (розглянемо це питання нижче).
Для всіх ситуацій, окрім тих, де вам не потрібно (дійсно потрібно) зберігати якійсь дані у своїй абстракції краще використовувати цей вид абстракцій. Але, розгляньмо й інший варіант того, як це можна зробити:
Abstract class
Іншим варіантом реалізації абстракції – є абстрактний класс. Він може все що й звичайний клас, але може мати не ініціалізованих членів класу (функції або властивості) та не може бути зконструйований викликом конструктора. Може бути тільки батьківським классом (тобто, реалізується через спадкоємця через спадкування).
Розгляньмо на прикладі.
abstract class Foo(var name: String) { // він також має конструктор
abstract val someNumber: Int
abstract fun isEarthRound(): Boolean
}
class Bar : Foo("Bar") { // викликаємо конструктор при наслідуванні
// залишимо не ініціалізованими члени класу, який наслідуємо.
}
Ми не ініціалізували члени класу, який наслідуємо тому, при спробі запуску, отримаємо помилку:
Class 'Bar' is not abstract and does not implement abstract base class member public abstract val someNumber:
Int defined in Foo
Class 'Bar' is not abstract and does not implement abstract base class member public abstract fun isEarchRound():
Boolean defined in Foo
До речі, абстрактний клас може наслідувати абстрактний клас (і також інтерфейс може наслідувати інтерфейс).
Тому нам потрібно реалізувати наш клас:
class Bar : Foo("Bar") { // викликаємо конструктор при наслідуванні
override val someNumber: Int = 1000
override fun isEarthRound() = false // а ви шо думали?
}
Але, що якщо ми хочемо зробити абстракцію можливою до використання без спадкоємця (наслідника)?
Open class
Цей вид класів може бути як віднаслідуваним, так і просто створеним:
open class Foo {
fun isEarthRound(): Boolean = false
}
Цей клас може бути створеним:
fun main() {
val foo = Foo()
println("Is Earth round? ${foo.isEarthRound()}")
}
І також може мати спадкоємця:
class Bar : Foo() {
override fun isEarthRound() = true
}
Начебто, все окей, але Kotlin нам скаже наступне:
'isEarthRound' in 'Foo' is final and cannot be overridden
Насправді таке ж би було, якщо ми б захотіли ініціалізувати в абстрактному класі не абстрактного члена.
Тому, за аналогією абстрактних членів, додамо до функції модифікатор open
.
open class Foo {
open fun isEarthRound(): Boolean = false
}
Після чого ми вже зможемо переназначити (ініціалізувати) функцію:
class Bar : Foo() {
override fun isEarthRound() = true // тепер все ок
}
Для того, щоб дізнатись більше, чому всі члени в Kotlin за замовчуванням final
(фінальні, тобто їх вже не можна
змінювати), можна прочитати про кризис базового класу.
Рішення
Але перейдім все ж таки до того, як ми розв'яжемо нашу задачу.
Насправді нам мало чим підходить open class
, бо в нас немає ніякого окремого 'Pet', а є конкретна тварина.
Абстрактний клас нам не підходить, бо в нас немає ніяких початкових значень та взагалі чогось, що було
б визначено початково (у нас все має визначати спадкоємець). Тому напишим варіантом буде interface:
// до речі всі члени interface за замовчуванням `open`
interface Pet {
val name: String
val age: Int
fun sound(): String
}
І віднаслідуємо:
class Cat(override val name: String, override val age: String) : Pet {
override fun sound(): String = "meow<3"
}
class Dog(override val name: String, override val age: String) : Pet {
override fun sound(): String = "aww!"
override fun run() {
println("pretend dog is running..")
}
}
class Perrot(override val name: String, override val age: String) : Pet {
override fun sound(): String = "squawk!"
override fun fly() {
println("pretend perrot is flying..")
}
}
І створім функцію, що буде використовувати нашу абстракцію:
fun printSound(pet: Pet) {
println(pet.sound())
}
І викличемо цю функцію:
fun main() {
val cat = Cat("Мася", 4)
val dog = Dog("Мопс", 1)
val perrot = Perrot("Жан", 2)
printSound(cat)
printSound(dog)
printSound(perrot)
}
Для прикладу поки зробили так.
До речі, а як нам в подібній функції перевірити, яка сама реалізація була передана аргументом?
Наприклад, для того, щоб виконати унікальну дію нашого об'єкта (fly()
або run()
).
Для цього існують два оператори:
is
: оператор, який говорить, чи є екземпляр вказаним об'єктом:if(pet is Dog)
pet.run()as
: оператор приведення типа до якогось іншого:(pet as Dog).run() // може бути помилка, бо перевірка типа не відбувається
Рекомендую використовувати перший оператор завжди, коли ви не впевнені в тому, що параметр не є конкретно переданим типом об'єкта.
Зробім же нашу функцію повноцінно:
fun doUniqueAction(pet: Pet) {
when {
pet is Cat -> println("я просто лінивий")
pet is Dog -> pet.run()
pet is Perrot -> pet.fly()
}
}
І у нас все готово, але, до речі, це можна спростити:
fun doUniqueAction(pet: Pet) {
when(pet) {
is Cat -> println("я просто лінивий")
is Dog -> pet.run()
is Perrot -> pet.fly()
}
}
Викличемо нашу функцію:
fun main() {
val cat = Cat("Мася", 4)
val dog = Dog("Мопс", 1)
val perrot = Perrot("Жан", 2)
doUniqueAction(cat)
doUniqueAction(dog)
doUniqueAction(perrot)
}
Ось ми й зробили нашу абстракцію!
До речі, будь-який об'єкт за замовчуванням спадкує клас Any
. Наприклад, за допомогою цього,
у будь-якого об'єкта є toString()
.
Але ніякої магії в цій функції немає: кожен спадкоємець, якщо хоче мати toString()
, має його реалізувати:
class Foo(..) {
...
override fun toString() = "my awesome string representation of object"
}
Раніше ми розглядали базові типи Kotlin, що вже за замовчуванням мають реалізацію цих функцій. Але, з нашими об'єктами нам знадобиться робити це вручну (але насправді рідко коли це потрібно) або використовувати інші типи класів, про які ми поговоримо згодом.