안드로이드 개발자 노트
[코틀린 완벽 가이드] 8-1장 : 상속 본문
상속 (클래스 계층 이해하기)
1. 하위 클래스 선언
클래스 정의에서 주생성자 뒤에 :을 넣고 상위 클래스가 될 클래스의 이름을 넣으면 클래스를 상속할 수 있다.
class 앞에 open 키워드는 클래스가 상속에 대해 열려있다는 뜻으로, 디폴트로 모든 클래스가 열려있고 상속을 금지하려면 final을 명시해야하는 자바와는 달리 코틀린 클래스는 디폴트가 final이고 이 변경자가 붙여야만 상속할 수 있다.
open class Vehicle {
var currentSpeed = 0
fun start() {
println("I'm moving")
}
fun stop() {
println("Stopped")
}
}
open class FlyingVehicle : Vehicle() {
fun takeOff() {
println("Taking off")
}
fun land() {
println("Landed")
}
}
class Aircraft(val seats: Int) : FlyingVehicle()
class Airbus(val seats: Int) : Aircraft(seats) // error
하위 클래스의 인스턴스는 상위 클래스의 인스턴스이기도 하며 하위 클래스 인스턴스는 상위 클래스의 멤버를 모두 상속한다.
val aircraft = Aircraft(100)
val vehicle: Vehicle = aircraft // 상위 타입으로 암시적으로 변환
vehicle.start() // Vehicle의 메서드를 호출
vehicle.stop() // Vehicle의 메서드를 호출
aircraft.start() // Vehicle의 메서드를 호출
aircraft.takeOff() // FlyingVehicle의 메서드를 호출
aircraft.land() // FlyingVehicle의 메서드를 호출
aircraft.stop() // Vehicle의 메서드를 호출
println(aircraft.seats) // Aircreft 자체 프로퍼티 접근
println(aircraft.currentSpeed) // Vehicle의 프로퍼티 접근
몇몇 종류의 클래스들은 상속을 제한적으로 지원한다.
- 데이터 클래스(data class): 항상 final이며 open으로 선언할 수 없다.
- 인라인 클래스(inline class, value class): 다른 클래스를 상속할 수도, 다른 클래스의 상위 클래스 역할을 할 수도 없다.
- 객체(object): 자유롭게 열린 클래스를 상속할 수 있지만, 모든 객체(object)는 인스턴스가 하나뿐이기 때문에 객체를 상속하거나 객체를 open으로 선언할 수는 없다.
// error: Modifier 'open' is incompatible with 'data'
open data class Person(val name: String, val age: Int)
class MyBase
// error: Value classes can be only final
open value class MyString(val value: String)
// error: This type is final, so it cannot be inherited from Value class cannot extend classes
value class MyStringInherited(val value: String) : MyBase()
open class Person(val name: String, val age: Int) {
companion object : Person("Unknown", 0)
}
object JohnDoe : Person("John Doe", 30))
임의 다형성(ad-hoc ploymorphism)은 상위 클래스 멤버의 구현을 하위 클래스에서 제공하고, 런타임에 실제 인스턴스가 속한 클래스에 따라 구현을 선택해주는 기능이다.
open class Vehicle {
open fun start() {
println("I'm moving")
}
fun stop() {
println("Stopped")
}
}
class Car : Vehicle(){
override fun start() {
println("I'm moving")
}
}
class Boat : Vehicle() {
override fun start() {
println("I'm sailing")
}
}
open 클래스의 메서드를 open으로 지정하면 하위 클래스에서 오버라이드할 수 있다.
하위 클래스에서는 상위 클래스의 메서드를 오버라이드하게 되면 메서드를 override 키워드를 붙여 사용할 수 있으며, 이런 식의 메서드 호출은 런타임에 그 인스턴스의 클래스가 무엇인지에 따라 달라진다.
fun startAndStop(vehicle: Vehicle) {
vehicle.start()
vehicle.stop()
}
fun main() {
startAndStop(Car())
startAndStop(Boat())
}
// I'm moving
// Stopped
// I'm sailing
// Stopped
// stop()은 final이기 때문에 오버라이드 하지 못하고 단순히 상속만 한다.
클래스 멤버와 확장의 차이점
- 클래스 멤버는 오버라이드할 수 있고 그에 따라 런타임에 인스턴스의 구체적인 타입에 따라 어떤 구현이 호출될지 결정할 수 있다.
- 확장은 항상 정적으로 호출할 대상이 결정된다. 컴파일러는 항상 정적으로 알려진 수신 객체 타입을 기반으로 호출할 확장을 선택한다.
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")
}
}
fun Car.stop() {
println("Stopped riding")
}
fun main() {
val vehicle: Vehicle = Car()
vehicle.start() // I'm riding
vehicle.stop() // Stopped moving
}
위와 같은 차이점 때문에 이 예제에서는 이런 결과가 나오게 된다.
start()는 클래스의 멤버 메서드이기 때문에 런타임 타입에 의해 동적으로 결정되어 Car 클래스의 start()가 호출되지만, stop()는 클래스의 확장이기 때문에 Vehicle 변수의 정적인 타입에 의해 결정되어 Vehicle.stop() 이 호출된다.
오버라이드를 하는 멤버의 시그니처가 상위 클래스의 멤버 시그니처와 일치해야 한다.
open class Vehicle {
open fun start(speed: Int) {
println("I'm moving at $speed")
}
}
class Car : Vehicle(){
// 시그니처가 달라서 다른 메서드를 오버라이딩하려는 것으로 인식됨
override fun start() { // error: 'start' overrides nothing
println("I'm riding")
}
}
하지만 반환 타입을 더 하위 타입으로 바꾸는건 가능하다.
open class Vehicle {
open fun start(): String? = null
}
class Car : Vehicle(){
final override fun start() = "I'm riding a car"
}
또한 오버라이드하는 멤버를 final로 선언하면 더 이상 하위 클래스가 이 멤버를 오버라이드할 수 없다.
open class Vehicle {
open fun start() {
println("I'm moving")
}
}
open class Car : Vehicle(){
final override fun start() {
println("I'm rinding a car")
}
}
class Bus : Car() {
// error: 'start' in 'Car' is final and cannot be overridden
override fun start() {
println("I'm rinding a bus")
}
}
프로퍼티도 오버라이드할 수 있으며, 불변 프로퍼티를 가변 프로퍼티로 오버라이드할 수도 있다.
open class Entity {
open val name:String get() = ""
}
class Person1() : Entity() {
override val name: String
get() = super.name
}
class Person2(override val name: String) : Entity() // 주생성자 파라미터로 오버라이드할 수도 있다.
class Person3(override val name: String) : Entity() {
override var name: String = ""
}
멤버에 protected 접근 변경자를 사용하면 멤버의 영역을 하위 클래스의 영역으로만 제한한다.
open class Vehicle {
protected open fun onStart() { }
fun start() {
println("Starting up...")
onStart()
}
}
class Car : Vehicle() {
override fun onStart() {
println("It's a car")
}
}
fun main() {
val car = Car()
car.start() // Ok
// error: Cannot access 'onStart': it is protected in 'Car'
car.onStart()
}
함수나 프로퍼티를 오버라이드한 버전이 원래 버전을 재사용해야 하는 경우 멤버 참조 앞에 super 키워드를 붙이면 원래 버전을 참조할 수 있다.
open class Vehicle {
open fun start(): String? = "I'm moving"
}
open class Car : Vehicle() {
override fun start() = super.start() + " in a car"
}
fun main() {
println(Car().start()) // I'm moving in a car
}
2. 하위 클래스 초기화
상위 클래스에서 초기화(init)하는 상태가 하위 클래스 코드가 사용할 환경이 되기 때문에 항상 상위 클래스 초기화를 먼저 시행해야 한다.
어떤 클래스 A의 인스턴스를 생성하려고 시도하면 A는 자신의 생성자를 처리하기 전에 상위 클래스 생성자를 호출하고, 다시 이 상위 클래스 생성자는 자신의 상위 클래스 생성자를 호출하며, 이런 호출이 최상위 클래스에 이를 때까지 연쇄적으로 일어난다.
open class Vehicle {
init {
println("Initializing Vehicle")
}
}
open class Car : Vehicle() {
init {
println("Initializing Car")
}
}
class Truck : Car() {
init {
println("Initializing Truck")
}
}
fun main() {
Truck()
// Initializing Vehicle
// Initializing Car
// Initializing Truck
}
이 결과는 상위 클래스로부터 하위 클래스 순서로 초기화가 진행된다는 사실을 확인해볼 수 있다.
디폴트 생성자를 사용하지 않고 데이터를 상위 클래스 생성자에게 전달해야 한다면 위임 호출(delegating call)을 사용해야 한다. 아래 코드에서는 위임 호출을 사용하여 Person 클래스에 자신의 파라미터 중 name과 age를 넘긴다.
open class Person(val name: String, val age: Int)
open class Student(name: String, age: Int, university: String) : Person(name, age)
fun main() {
Student("Euan Reynolds", 25, "MIT")
}
위임 호출은 부생성자에도 적용할 수 있다.
open class Person {
val name: String
val age: Int
constructor(name: String, age: Int) {
this.name = name
this.age = age
}
}
open class Student(name: String, age: Int, university: String) : Person(name, age)
하위 클래스에서 부생성자를 사용하려면 위임 호출을 하위 클래스의 생성자 바로 뒤에 위치시켜야 하며, super 키워드는 부생성자가 상위 클래스의 생성자를 위임 호출한다는 사실을 컴파일러에게 알려준다. 주생성자 호출과의 차이점은 상위 클래스 이름에 괄호가 없다. 그 이유는 정의하고 있는 클래스에 주생성자가 없으므로 부생성자에서 위임 호출을 해야만 하기 때문이다.
open class Person (val name: String, val age: Int)
class Student : Person {
val university: String
constructor(name: String, age: Int, university: String) : super(name, age) {
this.university = university
}
}
하위 클래스에 주생성자가 있으면 부생성자가 상위 클래스를 위임 호출할 수 없다.
// error: This type has a constructor, and thus must be initialized here
class Student() : Person { // 주생성자인 ()를 추가함
val university: String
// error: Primary constructor call expected
constructor(name: String, age: Int, university: String) : super(name, age) {
this.university = university
}
}
하위 클래스에 주생성자를 아예 정의하지 않고 부생성자를 사용하면 상위 클래스에 여러 부생성자가 있어도 하위 클래스에서 상위 클래스의 생성자를 복수 지원할 수 있다.
open class Person {
val name: String
val age: Int
constructor(name: String, age: Int) {
this.name = name
this.age = age
}
constructor(firstName: String, familyName: String, age: Int) :
this("$firstName $familyName", age) { }
}
class Student : Person {
val university: String
constructor(name: String, age: Int, university: String) :
super(name, age) {
this.university = university
}
constructor(firstName: String, familyName: String, age: Int, university: String) :
super(firstName, familyName, age) {
this.university = university
}
}
다음은 코틀린에서 널이 될 수 없는 타입의 변수 값이 널이 될 수 도 있는 아주 드문 경우인 this 누출(leaking this)문제이다.
open class Person(val name: String, val age: Int) {
open fun showInfo() {
println("$name, $age")
}
init {
showInfo()
}
}
class Student(name: String, age: Int, val university: String) : Person(name, age) {
override fun showInfo() {
println("$name, $age (student at $university)")
}
}
fun main() {
Student("Euan Reynolds", 25, "MIT") // Euan Reynolds, 25 (student at null)
}
showInfo()가 상위 클래스(Person)의 초기화(init) 코드에서 호출되기 때문에 university 변수가 null로 나온다.
이런 상황을 this 누출이라고 부른다. 상위 클래스가 현재의 인스턴스(this)를 코드에 누출하는데, 현재 인스턴스는 아직 초기화되지 않은 인스턴스의 상태에 의존할 수 있기 때문이다. 프로그램은 상위 클래스가 초기화될 때 init의 showInfo()를 호출한다. 런타임 객체의 실제 타입이 Student이므로 오버라이드한 showInfo()를 호출하게 한다. 하지만 Person 초기화 코드는 Student가 초기화되기 전에 실행되므로 showInfo()가 호출되는 시점에 university 변수는 초기화되지 않은 상태다.
다음 코드를 보면 더 명확하게 알 수 있다.
open class Person(val name: String, val age: Int) {
override fun toString(): String = "$name, $age"
init {
println(this) // this는 여기서 Student이다.
}
}
class Student(name: String, age: Int, val university: String) : Person(name, age) {
override fun toString(): String = super.toString() + " (student at $university)"
}
fun main() {
// Euan Reynolds, 25 (student at null)
Student("Euan Reynolds", 25, "MIT")
}
3. 타입 검사와 캐스팅
코틀린은 타입 검사와 캐스팅 연산을 제공한다.
is 연산자는 왼쪽 피 연산자가 오른쪽에 주어진 타입인 경우 true를 반환하며, !is 연산자는 is와 반대인 연산을 한다.
왼쪽 피연산자의 정적 타입이 오른쪽에 오는 타입의 상위 타입인 경우에만 두 연산자를 사용할 수 있다.
Int 타입 값의 타입을 String 타입과 비교하는 것은 의미가 없기 때문에 컴파일러는 오류를 발생시킨다.
println(12 is String) // error: Incompatible types: String and Int
아래 예제에서는 obj를 Any 타입으로 받기 때문에 가능하다.
val objects = arrayOf("1", 2, "3", 4)
for (obj in objects) {
println(obj is Int)
}
// false
// true
// false
// true
null 값은 널이 될 수 있는 타입의 인스턴스로 간주되어 nullable 타입으로 비교하면 true, non-null 타입으로 비교하면 false를 반환한다.
println(null is Int) // false
println(null is String?) // true
null 스마트캐스트와 같이 is / !is 검사를 통해 식 내부에서도 스마트 캐스트가 지원이 된다.
val objects = arrayOf("1", 2, "3", 4)
var sum = 0
for (obj in objects) {
when (obj) {
is Int -> sum += obj // 여기서 obj는 Int 타입이다.
is String -> sum += obj.toInt() // 여기서 obj는 String 타입이다.
}
}
println(sum) // 10
컴파일러는 검사 시점과 사용 시점 사이에 변수가 변경되지 않는다고 확신할 수 있을 때만 스마트 캐스트를 허용한다.
위와 같은 이유로 프로퍼티나 커스텀 접근자가 정의된 변수에 대해서는 스마트 캐스트를 쓸 수 없다. 이런 유형의 변수에는 위임을 사용하는 프로퍼티나 지역 변수도 포함된다.
class Holder {
val o: Any get() = ""
}
fun main() {
val o: Any by lazy { 123 }
if (o is Int) {
// error: Smart cast to 'Int' is impossible, because 'o' is a property that has open or custom getter
println(o*2)
}
val holder = Holder()
if (holder.o is String) {
// error: Smart cast to 'String' is impossible, because 'holder.o' is a property that has open or custom getter
println(holder.o.length)
}
}
열림 멤버 프로퍼티의 경우, 하위 타입에서 이런 프로퍼티를 오버라이드하면서 커스텀 접근자를 추가할 수 있기 때문에 스마트 캐스트를 할 수 없는 범주에 속한다.
open class Holder {
open val o: Any get() = ""
}
fun main() {
val holder = Holder()
if (holder.o is String) {
// error: Smart cast to 'String' is impossible, because 'holder.o' is a property that has open or custom getter
println(holder.o.length)
}
}
가변 지역 변수의 경우, 검사하는 시점과 변수를 읽는 시점 사이에 값을 명시적으로 변경하거나 어떤 람다 안에서 변수를 변경하면 스마트 캐스트가 되지 않는다. 람다 내부에서 변수를 변경하는 코드의 경우, 일반적으로 런타임의 어느 시점에 변수가 변경될지 예측할 수 없기 때문이다. 반면 언제든 변경이 가능한 가변 프로퍼티는 스마트 캐스트 대상이 아니다.
위임이 없는 불변 지역 변수는 항상 스마트 캐스트가 가능하며, 이 사실이 불변 변수를 가변 변수보다 더 선호해야 하는 이유이기도 하다.
var o: Any = 123
if (o is Int) {
println(o + 1) // Ok: Int로 스마트 캐스트
o = ""
println(o.length) // Ok: String으로 스마트 캐스트
}
if (o is String) {
val f = { o = 123 }
println(o.length) // Error: 스마트 캐스트 불가능
}
스마트 캐스트를 쓸 수 없는 경우에도 명시적인 연산자를 사용해 어떤 값의 타입을 강제로 변환할 수 있다.
코틀린은 이런 연산자로 안전하지 않은 as와 안전한 버전인 as?를 제공한다. as는 예외를 던지지만 as?는 null을 돌려준다.
val o: Any? = 123
println((o as Int) + 1) // 124
println((o as? Int)!! + 1) // 124
println((o as? String ?: "").length) // 0
println((o as String).length) // ClassCastException
println(o as? String) // null
println(o as String?) // ClassCastException
// null을 널이 될 수 없는 타입으로 변환하려 하면 오류
println(null as String) // NullPointerException
4. 공통 메서드
모든 클래스는 정의하면서 상위 클래스를 명시하지 않으면 컴파일러가 자동으로 상위 클래스를 Any로 가정한다.
public open class Any {
public open operator fun equals(other: Any?): Boolean
public open fun hashCode(): Int
public open fun toString(): String
}
Any의 내부 구현을 보면 equals, hashCode, toString 메서드들이 있는데, 이 메서드들은 non-null 타입의 값에 대해 적용할 수 있는 기본 연산을 정의한다.
- equlas(): 구조적 동등성(==, !=)
- hashCode(): 해시 코드 계산, HashSet, HashMap등의 일부 컬렉션 타입이 해시 코드를 사용한다.
- toString(): String으로 변환하는 기본적인 방법
컴파일러는 클래스에 대한 '참조 동등성'과 데이터 클래스에 대한 '구조적 동등성'을 제공한다.
- 참조 동등성: 두 참조가 동일 객체를 가리킴 (주소)
- 구조 동등성: equals()로 프로퍼티를 검사 (내용)
class Address(
val city: String,
val street: String,
val house: String
)
open class Entity(
val name: String,
val address: Address
)
class Person(
name: String,
address: Address,
val age: Int
) : Entity(name, address)
class Organization(
name: String,
address: Address,
val manager: Person
) : Entity(name, address)
fun main() {
val addresses = arrayOf(
Address("London", "Ivy Lane", "8A"),
Address("New York", "Kingsway West", "11B"),
Address("Sydney", "North Road", "129"),
)
// -1
println(addresses.indexOf(Address("Sydney", "North Road", "129")))
}
디폴트로 모든 클래스에는 구조 동등성이 아닌 Any에서 상속받은 참조 동등성만 구현하기 때문에 컬렉션 객체로 이런 클래스들의 인스턴스를 사용하면 프로퍼티가 똑같더라도 두 인스턴스가 같은 객체로 간주되지 않는다.
equals() 메서드를 오버라이드해서 내용을 바탕으로한 구조 동등성을 비교하게 하면 이 문제를 해결할 수 있다.
equals() 구현의 일반적인 요구 사항은 기본적으로 자바와 같다.
- 널이 아닌 객체가 널과 같을 수 없다.
- 동등성 연산은 반사적이어야 한다. 즉, 모든 객체는 자기 자신과 동등해야 한다.
- 동등성 연산은 추이적이어야 한다. 즉, a == b 이고 b == c 이면 a == c 여야 한다.
class Address(
val city: String,
val street: String,
val house: String
) {
override fun equals(other: Any?): Boolean {
if (other !is Address) return false
return city == other.city &&
street == other.street &&
house == other.house
}
}
참조 동등성은 ===와 !==를 사용해 구현돼있다. equals()와 달리 이 참조 동등성 연산자를 오버라이드할 수는 없다.
val addr1 = Address("London", "Ivy Lane", "8A")
val addr2 = addr1 // 같은 인스턴스
val addr3 = Address("London", "Ivy Lane", "8A") // 다른 인스턴스이지만 동일함
println(addr1 === addr2) // true
println(addr1 == addr2) // true
println(addr1 === addr3) // false
println(addr1 == addr2) // true
equals()와 hashCode() 메서드는 서로 연관이 있어야 하고, equals()가 같다고 보고하는 두 객체는 항상 같은 hashCode()를 반환해야 한다. 이렇게 해야하는 이유는 일부 컬렉션(HashSet 등)이 hashCode()를 사용해서 해시 테이블에서 원소가 들어갈 슬롯을 먼저 찾고 그 후에 equals()를 통해 해시 코드가 같은 모든 후보를 검색하기 때문이다. 동등한 두 객체가 서로 다른 해시 코드를 반환하면, 이런 컬렉션은 두 객체에 대해 equals()를 호출하기도 전에 서로 다르다고 인식하고 검색에서 제외시켜 버리게 된다.
val addr1 = Address("London", "Ivy Lane", "8A")
val addr2 = addr1
val addr3 = Address("London", "Ivy Lane", "8A")
println(addr1 === addr2) // true
println(addr1 == addr2) // true
println(addr1 == addr3) // true
println("${addr1.hashCode()} ${addr3.hashCode()}") // 1504109395 2047526627
Address 클래스의 equals() 메소드와 호환되는 hashCode() 구현은 다음과 같다.
override fun hashCode(): Int {
var result = city.hashCode()
result = 31 * result + street.hashCode()
result = 31 * result + house.hashCode()
return result
}
모든 코틀린 클래스에는 toString() 메서드가 들어있다. 이 메서드는 주어진 인스턴스의 기본 문자열 표현을 제공한다. 디폴트 표현은 클래스 이름 뒤에 객체 해시 코드를 조합하는 방식이다. 따라서 대부분의 경우 이를 좀 더 읽기 좋은 표현으로 오버라이드하는 편이 좋다.
class Address(
val city: String,
val street: String,
val house: String
) {
override fun toString(): String = "$city, $street, $house"
}
open class Entity(
val name: String,
val address: Address
)
class Person(
name: String,
address: Address,
val age: Int
) : Entity(name, address) {
override fun toString(): String = "$name, $age at $address"
}
class Organization(
name: String,
address: Address,
val manager: Person?
) : Entity(name, address) {
override fun toString(): String = "$name at $address"
}
fun main() {
// Euan Reynolds, 25 at London, Ivy Lane, 8A
println(Person("Euan Reynolds", Address("London", "Ivy Lane", "8A"), 25))
// Thriftocracy, Inc. at Perth, North Road, 129
println(
Organization(
"Thriftocracy, Inc.",
Address("Perth", "North Road", "129"),
null
)
)
}
'Kotlin > 코틀린 완벽 가이드' 카테고리의 다른 글
[코틀린 완벽 가이드] 8-2장 : 추상 클래스와 인터페이스 (0) | 2022.11.05 |
---|---|
[코틀린 완벽 가이드] 7-2장 : 파일과 I/O 스트림 (0) | 2022.10.29 |
[코틀린 완벽 가이드] 7-1장 : 컬렉션 (0) | 2022.10.23 |