본문 바로가기

코틀린

[코틀린] 상속(Inheritance)

 

안드로이드 개발하는 kancho입니다.

이번 포스팅에서는 코틀린의 상속(Inheritance)에 대해 알아보고자 합니다.

'코틀린 완벽 가이드' 책을 참고하였습니다.

 

 

상속

is-a 관계를 표현하기 위한 개념이다.

상위 클래스로부터 하위 클래스가 무엇인가를 물려받는 것이라고 생각할 수 있다.

 

subclass는 superclass에 정의된 멤버와 확장을 얻게 된다.

 

코틀린 클래스는 단일 상속만 가능하다. 즉 클래스의 상위 클래스가 최대 하나라는 것이다.

상위 클래스를 명시하지 않으면 자동으로 Any를 상속하는 것으로 가정한다.

모든 클래스는 class hierarchy (클래스 계층)을 구성한다.

 

상속은 콜론(:)으로 표시한다.

 

/*
 * open 변경자 : 상속 가능한 것을 의미. 해당 클래스를 상위 클래스로 지정할 수 있다
 * 클래스는 디폴트로 상속할 수 없는 final 클래스이다
 */
open class Vehicle {
    var speed = 0
    fun start() { println("I'm moving") }
    fun stop() { println("Stopped") }
}

/*
 * FlyingVehicle 클래스가 Vehicle 클래스를 상속
 * FlyingVehicle이 하위 클래스(파생 클래스), Vehicle이 상위 클래스(기반 클래스)
 * Vehicle()의 괄호는 상위 클래스의 생성자를 호출하기 위해서이다
 */

open class FlyingVehicle : Vehicle() {
    fun takeOff() { println("Taking off") }
    fun land() { println("Landed") }
}

class Aircraft(val seats: Int) : FlyingVehicle()

// 하위 클래스의 instance는 상위 클래스의 instance이기도 하다
fun main() {
    val aircraft = Aircraft(100)
    val vehicle: Vehicle = aircraft

    vehicle.start()     // "I'm moving"
    vehicle.stop()      // "Stopped"
    aircraft.start()    // "I'm moving"
    aircraft.takeOff()  // "Taking off"
    aircraft.land()     // Landed
    println(aircraft.seats) // 100
}

 

 

open 변경자가 붙지 않은 클래스는 디폴트로 final 클래스이며 상속이 불가능하다.

자바 코드로 보면 Aircraft 클래스는 final이 붙고 Vehicle 클래스는 final이 붙지 않은 것을 볼 수 있다.

 

// Aircraft
public final class Aircraft extends FlyingVehicle {
   private final int seats;

   public final int getSeats() {
      return this.seats;
   }

   public Aircraft(int seats) {
      this.seats = seats;
   }
}

// Vehicle
public class Vehicle {
   ...
}

 

 

제한적으로 상속을 지원하는 클래스들도 있다.

 

데이터 클래스는 open으로 선언할 수 없다. 항상 final이다

 

인라인 클래스는 항상 final이고 다른 클래스의 상위 클래스가 될 수 없다.

 

// error: Modifier 'open' is incompatible with 'data'
open data class Person(val name: String)

class MyBase

// error: Value classes can be only final
open value class MyString(val value: String)

// error: Value class cannot extend classes
value class MyString(val value: String): MyBase()

 

 

객체(object)는 다른 클래스를 상속할 수 있다.

객체를 상속하거나 open으로 선언할 수는 없다. (객체는 인스턴스가 단 하나이기 때문에)

 

open class Person(val name: String) 
object Kancho: Person("Kancho")

 

 

 

상속은 임의 다형성(ad-hoc polymorphism) 기능을 제공한다.

임의 다형성은 상위 클래스 멤버의 여러 다른 구현을 하위 클래스에서 제공하고

런타임에 실제 인스턴스가 속한 클래스에 따라 구현을 선택해주는 기능이다.

 

코틀린은 상위 클래스의 멤버를 override 해서 임의 다형성을 표현할 수 있다.

 

/* start() 메소드에 open 변경자를 붙여 Vehicle 클래스를 상속하는
 * 하위 클래스들에게 공통 구현을 제공한다
 */
open class Vehicle {
    open fun start() {
        println("I'm moving")
    }
    fun stop() {
        println("Stopped")
    }
}

/* start() 메서드를 오버라이드
 * super를 통해 상위 클래스의 start 메서드를 호출할 수 있다
 * override 메서드는 상위 클래스의 메서드와 구조(시그니처)가 같아야 한다
 */ 
class Car : Vehicle() {
    override fun start() {
        super.start()
        println("I'm riding a car")
    }
}

// Runtime에 vehicle의 인스턴스의 클래스에 따라 start() 메서드를 호출한다
fun startAndStop(vehicle: Vehicle) {
    vehicle.start()
    vehicle.stop()
}

// startAndStop 함수의 vehicle 인스턴스는 Car이다
fun main() {
    val car = Car()
    startAndStop(car)
}

/*
 * I'm moving
 * I'm riding a car
 * Stopped
 */

 

 

Vehicle 클래스를 살펴보면

open 변경자가 붙은 start 메서드는 final이 아니지만

변경자가 붙지 않은 stop 메서드는 final이기에 오버라이드 할 수 없고 단순히 상속만 가능하다.

 

public class Vehicle {
   public void start() {
      String var1 = "I'm moving";
      System.out.println(var1);
   }

   public final void stop() {
      String var1 = "Stopped";
      System.out.println(var1);
   }
}

 

 

코틀린과 자바의 상속은 크게 2가지 중요한 차이점이 있다.

 

1. 코틀린 함수와 프로퍼티는 디폴트로 final이며 open을 명시해야 override 가능
자바는 메서드와 프로퍼티가 암시적으로 open이며 override를 막기 위해 final을 붙인다

2. 코틀린은 오버라이드 하는 경우 override 키워드를 붙여야 한다
자바는 @Override annotation을 권장하지만 필수는 아니다

 

 

확장은 항상 정적으로 알려진 수신 객체 타입을 기반으로 호출할 대상이 결정된다.

 

open class Vehicle {
    open fun start() {
        println("I'm moving")
    }
}

fun Vehicle.stop() {
    println("stopped moving")
}

class Car : Vehicle() {
    override fun start() {
        println("I'm riding a car")
    }
}

fun Car.stop() {
    println("stopped riding")
}

// start는 Car 클래스의 start를 호출(런타임 타입에 의한 동적)
// stop은 vehicle 변수의 정적 타입(Vehicle)에 의해 Vehicle.stop이 호출
fun main() {
    val vehicle: Vehicle = Car()
    vehicle.start() // I'm riding a car
    vehicle.stop()  // "stopped moving"
}

 

 

오버라이드 한 메서드의 반환 타입을 하위 타입으로 바꿀 수 있다.

 

open class Vehicle {
    open fun start(): String? = null
}

// String?의 하위 타입인 String으로 변경
class Car : Vehicle() {
    override fun start() = "I'm riding a car"
}

 

 

오버라이드 하는 멤버를 final로 선언하면 하위 클래스가 이 멤버를 오버라이드 할 수 없다.

 

open class Vehicle {
    open fun start() {
        println("I'm moving")
    }
}

/* 오버라이드한 start() 메서드를 final로 선언
 * Car 클래스를 상속하는 하위 클래스는 더 이상 start 메서드를 오버라이드 할 수 없다
 */
open class Car : Vehicle() {
    final override fun start() {
        println("I'm riding a car")
    }
}

class Bus : Car() {
    // error : 'start' in 'Car' is final and cannot be overridden
    override fun start() {
        println("I'm riding a bus")
    }
}

 

 

Property 오버라이드

 

open class Entity {
    open val name: String
        get() = ""
}

// 방법 1 : 주 생성자
class Person(override val name: String) : Entity()

// 방법 2 : 본문
class Person : Entity() {
    override val name: String
        get() = super.name
}

// 불변 프로퍼티를 가변 프로퍼티로 오버라이드
class Person : Entity() {
    override var name: String = ""
}

 

 

protected 접근 제한자를 통해 하위 클래스 영역으로만 제한할 수 있다.

 

open class Vehicle {
    protected open fun onStart() {
        println("onStart")
    }
    open fun start() {
        println("I'm moving")
    }
}

class Car : Vehicle() {
    override fun onStart() {
        println("It's a car")
    }
}

fun main() {
    val car = Car()
    car.start()		// "I'm moving"
    car.onStart()	// error : Cannot access 'onStart': it is protected in 'Car'
}

 

 

하위 클래스 초기화

 

// 최상위 클래스인 Any 클래스 초기화 코드 실행
open class Vehicle {
    init { println("init Vehicle") }
}

// 상위 클래스인 Vehicle 클래스 생성자를 먼저 호출
open class Car : Vehicle() {
    init { println("init Car") }
}

// 상위 클래스인 Car 클래스 생성자를 먼저 호출
class Truck : Car() {
    init { println("init Truck") }
}

// 상위 클래스 -> 하위 클래스로 초기화 진행
fun main() {
    Truck()
}

/* init Vehicle
 * init Car
 * init Truck
 */

 

 

상위 클래스에 데이터를 전달

 

위임 호출(delegating call) 사용

 

open class Person(val name: String, val age: Int)

// Student의 주 생성자는 위임 호출을 통해
// 파라미터 3개 중 2개를 Person 생성자로 넘김다
class Student(name: String, age: Int, val university: String) : Person(name, age)

// 부 생성자를 사용한 위임 호출
// 상위 클래스인 Person에 괄호가 없다 (주 생성자가 없기 때문)
class Student : Person {
    val university: String
    constructor(name: String, age: Int, university: String) : super(name, age) {
        this.university = university
    }
}

fun main() {
    Student("Kancho", 30, "CAU")
}

 

 

상위 클래스에 주 생성자가 있을 경우 하위 클래스의 부 생성자가 상위 클래스를 위임 호출할 수 없다.

 

open class Person(val name: String, val age: Int)

// This type has a constructor, and thus must be initialized here
class Student() : Person {
    // error : Primary constructor call expected
    constructor(name: String, age: Int, university: String) : super(name, age)
}

 

 

this 누출(leaking this)

Student 인스턴스를 생성하면

  1. Person 생성자 호출
  2. Person 초기화 코드에서 showInfo 메서드 호출
  3. 다형성으로 인해 런타임 때 Student 클래스의 showInfo 메서드 실행
  4. Kancho, 30 (student at null) 출력
  5. Student 생성자 초기화

상위 클래스가 현재의 인스턴스를 코드에 노출하게 된다.

현재 인스턴스는 아직 초기화되지 않은 인스턴스의 상태에 의존할 수 있기 때문에 this 누출이라고 한다.

this 누출 문제는 코틀린의 non-null 타입의 변수 값이 null이 될 수 있는 문제가 있다.

 

open class Person(val name: String, val age: Int) {
    open fun showInfo() {
        println("$name, $age")
    }

    init {
        showInfo()
    }
}

class Student(
    name: String,
    age: Int,
    val univ: String
) : Person(name, age) {
    override fun showInfo() {
        println("$name, $age (student at $univ)")
    }
}

fun main() {
    val student = Student("Kancho", 30, "CAU")
}

/* result ->
 * Kancho, 30 (student at null)
 */

 

 

타입 검사와 캐스팅

 

타입 검사

 

is 연산자 사용

 

fun main() {
    // 컴파일러는 Any 타입의 배열로 인식한다
    val objects = arrayOf("1", 2, "3", 4)

    // is 연산자 통한 타입 검사, 배열 중 Int 타입만 true 반환
    for (obj in objects) {
        println(obj is Int)	// false, true, false, true
    }
    

    // null은 nullable 타입의 인스턴스
    println(null is Int)    	// always false
    println(null is String?)    // always true

    // !is 연산자 사용
    val o: Any = ""
    println(o !is Int)  	// true
    println(o !is String)   	// false
}

 

스마트 캐스팅

변수가 변경되지 않는다고 확신할 때만 스마트 캐스트를 허용한다.

 

fun main() {
    val objects = arrayOf("1", 2, "3", 4)
    var sum = 0

    for (obj in objects) {
        if (obj is Int) {
            sum += obj  // obj -> Int
        }
    }
    
    for(obj in objects) {
        when (obj) {
            is Int -> sum += obj    		// obj -> Int
            is String -> sum += obj.toInt() 	// obj -> String
        }
    }
}

 

 

스마트 캐스트가 적용되지 않는 경우

  1. 위임을 사용하는 property
  2. custom getter를 가진 property
  3. open 변경자가 붙은 property
  4. mutable 변수의 경우 람다 안에서 변수가 변경될 경우
  5. 다른 코드에서 언제든 변경이 가능한 mutable property

 

fun main() {
    val o: Any by lazy { 123 }
    if (o is Int) {
        println(o * 2)  // error: 위임을 사용하는 프로퍼티에는 스마트 캐스트 X
    }

    val holder = Holder()
    if (holder.o is String) {
        println(holder.o.length)    // error: 커스텀 접근자로 정의된 변수는 스마트 캐스트 X
    }

    val openHolder = OpenHolder()
    if (holder.o is String) {
        // error: open 멤버 프로퍼티의 경우 하위 타입에서 오버라이드하면서 커스텀 접근자를 추가할 수 있기 때문에 스마트 캐스트 X
        println(holder.o.length)
    }

    var a: Any = 123
    if (a is String) {
        val f = { a = 123 }
        println(a.length)   // 가변 지역 변수일 때 람다 안에서 변수를 변경할 경우 스마트 캐스트 X
    }
}

 

 

위임이 없는 immutable 지역 변수는 항상 스마트 캐스트가 가능하다.

불변 변수를 가변 변수보다 더 선호해야 하는 이유이기도 하다.

 

스마트 캐스트를 사용할 수 없는 경우에도 as, as? 연산자를 통해 타입을 강제로 변환할 수 있다.

as는 변환하려는 대상 타입과 일치하지 않을 때 예외를 던지고

as?는 null을 리턴한다.

 

fun main() {
    val o: Any = 123
    println((o as Int) + 1) 		// 124
    println((o as? Int)!! + 1)  	// 124
    println(o as? String)   		// null
    println((o as String).length)   // ClassCastException
    
    // null -> non-null 타입으로 변환 시 NPE
    println(null as String) // NPE
}

 

 

공통 메서드

 

코틀린에서 모든 클래스는 최상위 클래스인 Any 클래스를 직간접적으로 상속한다.

 

// Any.kt 클래스, JVM에서 Any는 런타임 시 Object으로 표현
public open class Any {
    // 구조적 동등성. operator를 통해 연산자 형태(==, !=)로 호출될 수 있다
    public open operator fun equals(other: Any?): Boolean
    // 해시코드 계산
    public open fun hashCode(): Int
    // String으로 변환
    public open fun toString(): String
}

 

 

동등성 연산

 

class Address(val city: String, val street: String)

open class Entity(val name: String, val addr: Address)

class Person(name: String, addr: Address, val age: Int): Entity(name, addr)

class Organization(name: String, addr: Address, val manager: Person): Entity(name, addr)

fun main() {
    val addresses = arrayOf(
        Address("Seoul", "gangnam"),
        Address("Busan", "haeundae"),
        Address("Incheon", "songdo")
    )

    // -1, Address의 프로퍼티는 같지만 같은 객체로 간주되지 않는다
    println(addresses.indexOf(Address("Busan", "haeundae")))
}

 

equals() 메서드

non-null 객체가 null과 같을 수 없다.

 

  • 동등성 연산은 반사적이다. 모든 객체는 자기 자신과 동등해야 한다.
  • 동등성 연산은 대칭적이다. a == b이면 b == a이다
  • 동등성 연산은 추이적이다. a == b이고 b == c이면 a ==c이다.

 

equals() 메서드를 오버라이드 해서 사용하면 동등성 비교를 통해 같은 객체로 비교할 수 있다.

==, != 연산자가 공통으로 equals() 메서드를 사용한다.

 

equals() 메서드의 커스텀한 구현은 hashCode() 메서드와 연관이 있다.

equals()를 통한 값이 true이면 항상 같은 hashCode를 반환해야 한다.

 

class Address(val city: String, val street: String) {
    override fun equals(other: Any?): Boolean {
        if (other !is Address) return false
        return city == other.city && street == other.street
    }
}

 

참조 동등성은 ===!==를 사용한다.

 

fun main() {
    val addr1 = Address("Seoul", "gangnam")
    val addr2 = addr1
    val addr3 = Address("Seoul", "gangnam")

    println(addr1 === addr2)    // true
    println(addr1 === addr3)    // false
    println(addr1 == addr3)     // true
}

 

 

equals와 hashCode를 적용한 코드

// 프로퍼티는 equals()와 hashCode() 구현을 통해 해시와 동등성 계산
// 배열은 예외. 자체적인 내용 기반 동등성 구현이 없다
open class Entity(val name: String, val addr: Address) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false

        other as Entity
        if (name != other.name) return false
        if (addr != other.addr) return false

        return true
    }

    override fun hashCode(): Int {
        var result = name.hashCode()
        result = 31 * result + addr.hashCode()
        return result
    }
}

// 상위 클래스(Entity)가 커스텀한 equals(), hashCode()를 제공하면
// Person 클래스도 아래와 같은 구현을 갖는다
class Person(
    name: String,
    addr: Address,
    val age: Int
) : Entity(name, addr) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false
        if (!super.equals(other)) return false

        other as Person

        if (age != other.age) return false

        return true
    }

    override fun hashCode(): Int {
        var result = super.hashCode()
        result = 31 * result + age
        return result
    }
}

 

 

toString() 메서드

인스턴스의 기본 문자열 표현을 제공한다.

디폴트는 클래스 이름 뒤에 객체 해시 코드를 조합하는 방식이다.

가독성을 위해 오버라이드 해서 사용하는 것이 좋다.

 

class Address(val city: String, val street: String) {
    override fun toString(): String = "$city, $street"
}

open class Entity(val name: String, val addr: Address)

class Person(
    name: String,
    addr: Address,
    val age: Int
) : Entity(name, addr) {
    override fun toString(): String = "$name, $age at $addr"
}

fun main() {
    val person = Person("Kancho", Address("Seoul", "Gangnam"), 30)
    println(person)		// Kancho, 30 at Seoul, Gangnam

    val entity = Entity("Kancho", Address("Seoul", "Gangnam"))
    println(entity)		// Entity@4f023edb
}

 

'코틀린' 카테고리의 다른 글

[코틀린] Annotation  (0) 2022.11.21
[코틀린] 제네릭  (0) 2022.11.13
[코틀린] Inline 클래스  (0) 2022.10.23
[코틀린] Data 클래스  (1) 2022.10.23
[코틀린] Enum 클래스  (0) 2022.10.21