-
8-2. 클래스 계층 이해하기 : 추상 클래스와 인터페이스Study(종료)/Kotlin 22.09.13 ~ 12.18 2022. 11. 6. 20:46
8장
8-2. 클래스 계층 이해하기 : 추상 클래스와 인터페이스
8-2. 클래스 계층 이해하기 : 추상 클래스와 인터페이스
8-1장에서 본 모든 상위 클래스는 자기 자신에 대한 인스턴스를 만들 수 없는 타입들이었다.
하지만 별도의 인스턴스가 있을 수 없고 구체적인 경우를 구현한 다른 클래스의 인스턴스만 만들 수 있는
추상적인 개념은 표현하는 클래스라면 인스턴스 생성은 바람직하지 않다.
8-1장에서 사용한 Person과 Organization이 상속한 Entity 클래스의 경우를 예시로 들 수 있다.
하지만 Entity 자체는 추상적인 개념이다.
따라서 구체적인 하위 클래스의 인스턴스가 아닌 Entity 인스턴스를 생성하는 것은 의미가 없다
이제부터 코틀린에서 이런 추상적인 타입을 정의하고 사용하는 방법을 작성해보겠다.
1) 추상 클래스와 추상 멤버
코틀린은 자바와 마찬가지로 추상(Abstract)클래스를 지원한다.
추상 클래스란 직접 인스턴스화 할 수 없고 다른 클래스의 상위 클래스 역할만 할 수 있는 클래스이다.
클래스를 추상 클래스로 만드려면 abstract 변경자 키워드를 붙여야 한다.
abstract class Entity(val name: String) open class Person(name: String, val age: Int) : Entity(name) // OK val entity = Entity("Unknown") // ERROR : Cannot create an instance of an abstract class
위 코드에서 추상 클래스도 생성자가 있을 수 있다는 사실을 알 수 있다.
추상 클래스와 일반 클래스의 차이는 추상 클래스의 생성자가 오직 하위 클래스 생성자에서
위임 호출로만 호출될 수 있다는 점 이다.
abstract class Entity(val name: String) open class Person : Entity{ constructor(name: String) : super(name) constructor( firstName: String, familyName: String ) : super("$firstName $familyName") } // OK
추상 클래스의 또 다른 특징은 추상 멤버를 정의할 수 있다는 것이다.
추상 멤버는 타입, 파라미터, 반환 타입 등
함수나 프로퍼티의 기본적인 모습을 정의하지만 세부 구현을 생략한 멤버이다.
비추상 클래스가 추상적인 부모 클래스로부터 추상 멤버를 상속할 때
반드시 추상 멤버를 오버라이딩 해서 구현을 제공해야 한다.
import kotlin.math.PI abstract class Shape{ abstract val width: Double abstract val height: Double abstract fun area(): Double } class Circle(val radius: Double) : Shape(){ val diameter get() = 2 * radius override val width: Double get() = diameter override val height: Double get() = diameter override fun area() = PI * radius * radius } class Rectangle( override val width: Double, override val height: Double ) : Shape(){ override fun area() = width * height } fun Shape.print(){ println("Bounds: $width * $height, area: ${area()}") } fun main(){ Circle(10.0).print() // Bounds: 20.0 * 20.0, area: 314.1592653589793 Rectangle(3.0, 5.0).print() // Bounds: 3.0 * 5.0, area: 15.0 }
추상 멤버 자체는 구현을 가질 수 없으므로 추상 클래스에 추상 멤버를 정의할 때 몇가지 제약이 있다.
- 추상 프로퍼티를 초기화할 수 없고, 명시적인 접근자나 by절을 추가할 수 없다
- 추상 함수에는 본문이 없어야 한다
- 추상 프로퍼티와 함수 모두 명시적으로 반환 타입을 적어야 한다.
- > 본문이나 초기화 코드가 없으므로 타입 추론이 불가능하기 때문
추상 멤버는 암시적으로 open이므로 명시적으로 open을 지정할 필요가 없다.
intellij에서 ctrl + i 단축키를 누르면 구현하지 않은 추상 멤버를 자동으로 추가해주는 대화상자가 표시된다.
다른 방법으로는 클래스 이름이나 키워드에서 alt + enter를 누르면 나오는 퀵픽스를 사용하면 된다.
2) 인터페이스
인터페이스는 (추상, 비추상)메서드나 프로퍼티를 포함하지만 자체적인 인스턴스 상태나 생성자를 만들수는 없는 타입이다.
코틀린의 인터페이스 개념은 자바의 인터페이스와 상당히 비슷하다.
클래스와 달리 인터페이스 정의는 interface라는 키워드를 이용한다.
interface Vehicle{ val currentSpeed: Int fun move() fun stop() }
인터페이스의 멤버는 디폴트가 추상 멤버이다.
따라서 구현을 제공하지 않으면 abstract 변경자가 자동으로 붙은 것처럼 간주된다.
명시적으로 abstract를 붙일 수도 있지만, 불필요하다.
인터페이스는 클래스나 다른 인터페이스의 상위 타입이 될 수 있다.
비추상 클래스가 인터페이스를 상속할 때는 모든 추상 멤버에 대한 구현을 제공해야 한다.
(+ 비추상 멤버를 오버라이드 할 수 있다)
마찬가지로 클래스가 인터페이스를 상속한 클래스에 있는
인터페이스 멤버를 상속해 구현할 때도 override 키워드를 추가해야 한다.
말이 어려운데 아래 코드를 통해 살펴보자.
interface Vehicle{ val currentSpeed: Int fun move() fun stop() } interface FlyingVehicle : Vehicle{ val currentHeight: Int fun takeOff() fun land() } class Car : Vehicle{ override var currentSpeed = 0 private set override fun move() { println("Riding..") currentSpeed = 50 } override fun stop() { println("Stopped!") currentSpeed = 0 } } class Aircraft : FlyingVehicle{ override var currentSpeed = 0 private set override var currentHeight = 0 private set override fun move() { println("Taxing...") currentSpeed = 70 } override fun stop() { println("Stopped!!") currentSpeed = 0 } override fun takeOff() { println("Taking off..") currentSpeed = 500 currentHeight = 5000 } override fun land() { println("Landed") currentSpeed = 50 currentHeight = 0 } }
세 가지 타입 정의에서 클래스와 달리 인터페이스는 생성자가 없어서
하위 클래스를 초기화할 때 호출해야 할 코드가 없기 때문에 상위 타입 뒤에 괄호를 붙이지 않는다는 점을 기억해야 한다.
자바에서는 인터페이스를 상속할 때 impliments 키워드를 쓰고 클래스를 상속할 때는 extends를 써야 하지만
코틀린에서는 모든 상속을 똑같은 기호인 콜론( : )을 사용해 표시한다.
자바와 마찬가지로 코틀린 인터페이스가 다른 클래스를 상속할 수는 없다.
단, 모든 코틀린 클래스와 인터페이스가 암시적으로 상속하는 것으로 간주되는 Any 클래스는 예외이다.
인터페이스 안에 함수와 프로퍼티의 구현을 추가할 수도 있다.
interface Vehicle{ val currentSpeed: Int fun move() fun stop() val isMoving get() = currentSpeed != 0 fun report(){ println(if(isMoving) "Moving at $currentSpeed" else "Still") } }
위와 같은 구현은 암시적으로 열려 있는 것으로 간주된다.
따라서 상속하는 클래스에서 이런 멤버를 오버라이드할 수 있다.
인터페이스 멤버를 final로 정의하면 컴파일 오류가 발생한다.
하지만 확장 함수나 프로퍼티를 사용하면 final 멤버를 사용하는 것과 유사한 기능을 한다.
interface Vehicle{ final fun move() // ERROR : modifer 'final' is not applicable inside 'interface' } fun Vehicle.relativeSpeed(vehicle: Vehicle) = currentSpeed - vehicle.currentSpeed // OK
인터페이스를 인터페이스로 상속할 때도 메서드를 오버라이드 할 수 있다.
interface Vehicle{ fun move(){ println("I'm moving") } } interface Car : Vehicle{ override fun move() { println("Riding..") } }
인터페이스 내부에 상태를 정의할 수 없으므로,
인터페이스 안에는 backing-field가 들어있는 프로퍼티를 정의할 수 없다.
초기화 코드나 위임이 붙은 프로퍼티는 금지된다.
interface Vehicle{ val currentSpeed = 0 // ERROR : Property initializers are not allowed in interfaces val maxSpeed by lazy( 100 } //ERROR }
인터페이스는 암묵적으로 추상 타입이다.
하지만 추상 클래스와 달리 인터페이스에 대한 생성자는 금지된다.
interface Person(val name: String) // ERROR : An interface may not have a constructor interface Vehicle{ constructor(name: String) // ERROR : An interface may not have a constructor }
자바와 마찬가지로 코틀린 인터페이스도 다중 상속을 지원한다.
interface Car{ fun ride() } interface Aircraft{ fun fly() } interface Ship{ fun sail() } interface FlyingCar : Car, Aircraft class Transformer : FlyingCar, Ship{ override fun fly() { ... } override fun ride() { ... } override fun sail() { ... } }
FlyingCar 인터페이스와 Transformer 클래스는 모두 둘 이상의 인터페이스를 상속한다.
따라서 이들의 멤버를 모두 상속받으므로 비추상 Transformer 클래스는 상속받은 멤버를 모두 구현해야 한다.
동일한 시그니처를 가지는 멤버가 들어있는 둘 이상의 다른 인터페이스를 한 타입이 상속할 때
문제가 생긴다. 이 경우 이런 멤버들이 한 멤버로 합쳐지고 하위 타입은 이를 상속하는 것 같은 효과가 일어난다.아래 코드에서 Car와 Ship은 Any를 제외한 공통 상위 타입이 없다고 가정하자.
interface Car{ fun move() } interface Ship{ fun move() } class Amphibia : Car, Ship{ override fun move() { println("i'm moving") } }
위 코드에서 move() 메서드는 추상 메서드이다.따라서 비추상 클래스인 Amphibia에서는 move() 메서드를 구현해야 한다.하지만 상위 타입의 메서드 중에 구현이 존재하는 경우에도, 컴파일러는 모호성을 해결하기 위해 메서드를 명시적으로 구현하도록 강제한다.
interface Car{ fun move(){ println("I'm riding") } } interface Ship{ fun move() } class Amphibia : Car, Ship{ override fun move() { super.move() // Car.move() } } fun main(){ Amphibia().move() // I'm riding }
위와같은 경우에서 알 수 있듯이 합쳐지는 멤버에 대한 구현이 둘 이상의 상위 타입에 존재하는 경우,
super 호출 자체가 모호해진다. 이럴 때 super를 상위 타입으로 한정시킨 키워드를 사용해야 한다.
interface Car{ fun move(){ println("I'm riding") } } interface Ship{ fun move(){ println("I'm sailing") } } class Amphibia : Car, Ship{ override fun move() { // super.move() // ERROR // Many supertypes available, please specify the one you mean in angle brackets, e.g. 'super<Foo>' super<Car>.move() super<Ship>.move() } } fun main(){ Amphibia().move() // I'm riding // I'm sailing }
인터페이스에 생성자나 상태를 사용할 수 없다는 제약은 다중 상속을 지원하기 위한 것으로,
주 목적은 악명 높은 다이아몬드 상속(diamon inheritance) 문제를 방지하는 데 있다.
interface Vehicle{ val currentSpeed: Int } interface Car : Vehicle interface Ship : Vehicle class Amphibia : Car, Ship{ override var currentSpeed = 0 private set }
인스턴스의 상태를 허용하면 Vehicle 인터페이스에서 상태 변수로 currentSpeed를 정의할 수 있다.
그 결과 Amphibia 클래스에는 currentSpeed의 두 가지 복사본이 존재하게 된다.
Ship과 Car에서 상속한 것 인데, 두 가지 모두 Vehicle에서 물려받은 것 이다.코틀린 설계는 인터페이스에서 상태를 허용하지 않음으로써 이런 문제를 회피한다.프로그램 상태를 초기화하는 순서를 예측할 수 있으려면 생성자 사용 금지가 중요하다.
인터페이스에 생성자를 허용하면 초기화 순서 규칙이 다중 상속도 지원할 수 있게 확장해야 한다.이런 규칙을 따라서 초기화 순서를 프로그래머가 알아내기에는 상당히 힘들다.특히 상위 타입 관계 그래프에서 같은 인터페이스가 여러번 나타난 경우에는 더욱더 힘들어진다.
3) 봉인된 클래스와 인터페이스
종종 우리가 표현하고 싶은 개념이 몇 가지 정해진 변수의 집합으로 구성될 때가 있다.
이 경우 6장에서 배운 상수 집합을 표현하는 enum 클래스를 이용해 표현할 수 있다.
enum 클래스를 통해 어떤 계산의 성공 실패 여부를 표현할 수 있다.
fun runComputation(): Result{ try{ val a = readLine()?.toInt() ?: return Result.ERROR val b = readLine()?.toInt() ?: return Result.ERROR println("Sum: ${a + b}") return Result.SUCCESS }catch (e: NumberFormatException){ return Result.ERROR } } fun main(){ val message = when(runComputation()){ Result.SUCCESS -> "Completed successfully" Result.ERROR -> "ERROR!" } }
위 경우에서 좀 더 확장해보면어떤 경우에는 각 종류별로 애트리뷰트가 다를 수 있다.예를들어 성공인 경우 생성된 결과가 들어있고, 실패인 경우 실패 이유에 대한 정보가 들어있을 수 있다.이와 같은 개념은 클래스 계층을 활용해 모델링할 수 있다.클래스 계층의 루트에 있는 추상 클래스는 전체 개념을 표현하고, 하위 클래스들은 특정 변수을 표현한다.이러한 개념들을 이용해 위 코드를 좀 더 다듬어보자.
abstract class Result{ class Success(val value: Any) : Result(){ fun showResult(){ println(value) } } class Error(val message: String) : Result(){ fun throwException(){ throw Exception(message) } } } fun runComputation(): Result{ try{ val a = readLine()?.toInt() ?: return Result.Error("Missing first argument") val b = readLine()?.toInt() ?: return Result.Error("Missing first argument") return Result.Success(a + b) }catch (e: NumberFormatException){ return Result.Error(e.message ?: "Invalid input") } } fun main(){ val message = when(val result = runComputation()){ is Result.Success -> "Completed successfully : ${result.value}" is Result.Error -> "ERROR: ${result.message}" else -> return } }
하지만 위 코드에도 문제가 있다. 이 구현은 Result의 변수를 Success나 Error로 제한하지 못한다.
다른 클라이언트 코드가 아래와 같은 새로운 하위 클래스를 추가해도 막을 방법이 없다.
class MyStatus : Result()
그리고 이런 서브 클래싱이 가능하다는 점이 when 식에서 else가 필요한 이유이기도 하다.
컴파일러는 result 변수가 Success나 Error 인스턴스만 담는다는 사실을 알 수 없기 때문에
else 절을 추가하라고 강제한다.
코틀린에서는 위와 같은 문제를 해결하기 위해 봉인된 클래스(sealed class)나 인터페이스를 이용한다.
클래스 정의에 sealed 변경자를 추가해 사용하면 된다.
sealed class Result{ class Success(val value: Any) : Result(){...} class Error(val message: String) : Result(){...} }
어떤 클래스를 sealed로 지정하면 이 클래스를 상속하는 클래스는
내포된 클래스 또는 객체로 정의되거나 같은 파일 안에서 최상위 클래스로 정의돼야만 가능하다.
컴파일 단위 안의 같은 패키지에 있는 봉인된 클래스나 인터페이스는 상속할 수 있지만,이런 영역 밖에서는 봉인된 클래스는 final 클래스와 동일한 효과를 지닌다. (따라서 상속할 수 없다)
봉인된 클래스는 직접 인스턴스를 만들 수 없으므로 추상 클래스이기도 하다.봉인된 클래스의 인스턴스를 만들 때는 봉인된 클래스의 하위 클래스 중 하나를 선택해 만들어야 한다.
val result = Result() // ERROR : sealed types cannot be instantiated
실제로 봉인된 클래스의 생성자는 디폴트로 비공개(private)이고, 가시성을 다른 값으로 변경하면(public, ..)컴파일 에러가 발생한다.
enum과 마찬가지로 봉인된 클래스도 불필요한 else를 쓰지 않아도 되는 빠진 부분이 없는 when 형태를 지원한다.
val message = when(val result = runComputation()){ is Result.Success -> "Completed successfully : ${result.value}" is Result.Error -> "ERROR: ${result.message}" }
상속 제한은 봉인된 클래스를 직접 상속한 클래스에 대해서만 성립한다.
하위 클래스가 final이 아니라면 이를 상속한 하위 클래스가 있을 수 있다.
sealed class Result { class Success(val value: Any) : Result() open class Error(val message: String) : Result() } class FatalError(message: String): Result.Error(message) class Success(message: String): Result.Success(message) // ERROR // This type is final, so it cannot be inherited from
봉인된 클래스가 다른 클래스를 상속할 수도 있다.
이로 인해 봉인된 클래스의 하위 클래스가 다시 봉인된 클래스가 될 수도 있다.
sealed class Result sealed class Error : Result(){ abstract val message: String } class ErrorWithException(val exception: Exception) : Error(){ override val message: String get() = exception.message ?: "" } class ErrorWithMessage(override val message: String) : Error()
데이터 클래스의 상속 기능으로 인해 데이터 클래스가 봉인된 클래스 계층에 속할 수도 있다.이를 통해 데이터 클래스와 봉인된 클래스의 장점을 함께 취할 수 있다.예를 들어 간단한 연산자의 구문 트리(syntactic tree)를 표현하는 클래스의 코드를 보자.
sealed class Expr data class Const(val num: Int): Expr() data class Neg(val operand: Expr): Expr() data class Plus(val op1: Expr, val op2: Expr): Expr() data class Mul(val op1: Expr, val op2: Expr): Expr() fun Expr.eval(): Int = when(this){ is Const -> num is Neg -> -operand.eval() is Plus -> op1.eval() + op2.eval() is Mul -> op1.eval() * op2.eval() } fun main(){ val expr = Mul(Plus(Const(1), Const(2)), Const(3)) // (1 + 2) * 3 println(expr) // Mul(op1=Plus(op1=Const(num=1), op2=Const(num=2)), op2=Const(num=3)) println(expr.eval()) // 9 val expr2 = expr.copy(op1 = Const(2)) // 2 * 3 println(expr2) // Mul(op1=Const(num=2), op2=Const(num=3)) println(expr2.eval()) // 6 }
코틀린에서 클래스를 다중 상속 할 수는 없으므로
인터페이스에는 sealed 변경자를 적용할 수 없다.
(= 봉인된 클래스 계층을 이루는 하위 클래스가 봉인된 클래스 외에 다른 클래스를 상속할 수 없다)
봉인된 클래스를 객체로 구현할 수도 있다. Result의 결과를 세분화해서
실제 만들어진 값 없이 성공한 경우를 표현하고 싶다고 가정하면,
sealed class Result{ object Completed : Result() class ValueProduced(val value: Any) : Result() class Error(val message: String) : Result() }
모든 직접적인 상속자가 객체인 경우 봉인된 클래스는 결과적으로 이넘 클래스처럼 동작한다.
4) 위임
앞에서 코틀린 클래스가 기본적으로 final 이라는 사실을 배웠다.그렇다면 기존 클래스를 확장하거나 변경하고 싶을 때 어떻게 해야하는지 의문이 생긴다.이 경우 기존 클래스를 재사용하는 잘 알려진 패턴인 delegate pattern을 사용할 수 있다.어떤 인터페이스의 구현을 만들고 싶다면 이미 있는 구현의 인스턴스를 가져와서우리가 만드는 클래스로 감싸고, 필요할 때 인터페이스 메서드 구현을 이 기존 구현 인스턴스에 위임할 수 있다.
예시를 하나 들자면, 아래와 같은 타입이 있다고 가정하자.
interface PersonData{ val name: String val age: Int } open class Person( override val name: String, override val age: Int ): PersonData data class Book(val title: String, val author: PersonData){ override fun toString() = "'$title' by ${author.name}" } fun main() { val valWatts = Person("Val Watts", 30) val introKotlin = Book("Introduction to Kotlin", valWatts) println(introKotlin) // 'Introduction to Kotlin' by Val Watts }
그 다음 작가들이 다른 사람인 것 처럼 가장할 수 있는 필명을 사용할 수 있게 해보자
class Alias( private val realIdentity: PersonData, private val newIdentity: PersonData ): PersonData{ override val name: String get() = newIdentity.name override val age: Int get() = newIdentity.age }
위 클래스를 이용해 이제 작가가 가명을 만들 수 있다.
val valWatts = Person("Val Watts", 30) val johnDoe = Alias(valWatts, Person("John Deo", 25)) val introKotlin = Book("Introduction to Kotlin", johnDoe) println(introKotlin) // 'Introduction to Kotlin' by John Doe
이 접근 방식의 문제는 필요한 메서드나 프로퍼티를 다른 객체에 위임하기 위해
작성해야 하는 준비 코드가 너무 많다는 것 이다.
코틀린 내장된 위와 같은 위임을 처리하는 기능을 살펴보자.
먼저 상위 인터페이스 이름 바로 뒤에 by 키워드를 붙이고 그 다음 위임할 인스턴스를 작성하면 된다.
class Alias( private val realIdentity: PersonData, private val newIdentity: PersonData ): PersonData by newIdentity
이제 Alias가 PersonData 인터페이스에서 상속한 모든 멤버는
newIdentity 인스턴스에 있는 이름과 시그니처가 같은 메서드를 통해 구현된다.
구현을 변경하고 싶으면 직접 멤버를 오버라이드 하는것도 가능하다.
class Alias( private val realIdentity: PersonData, private val newIdentity: PersonData ): PersonData by newIdentity{ override val age: Int get() = realIdentity.age }
일반적으로 클래스 초기화 시 사용할 수 있는 대부분의 일을 위임으로 처리할 수 있다.
컴파일러는 위임된 값을 저장하는 필드를 자동으로 만들어준다.
예를들면 앞에 정의에서 newIdentity 파라미터에 붙은 val을 없애고
간단한 생성자 파라미터로 변경하는것도 가능하다.
class Alias( private val realIdentity: PersonData, newIdentity: PersonData ): PersonData by newIdentity
하지만 클래스 본문에 정의된 프로퍼티를 클래스 위임에 사용할수는 없다.
class Alias( private val realIdentity: PersonData, ): PersonData by newIdentity{ // ERROR : Unresolved reference: newIdentit val newIdentity = Person("John Deo", 25) }
위임과 객체 식을 조합하면 원래 객체와 약간 다른 구현을 만들 때 유용할 수 있다.
fun PersonData.aliased(newIdentity: PersonData) = object : PersonData by newIdentity{ override val age get() = this@aliased.age } fun main() { val valWatts = Person("Val Watts", 30) val johnDoe = valWatts.aliased(Person("John Deo", 25)) println("${johnDoe.name}, ${johnDoe.age}") // John Deo, 30 }
클래스는 인터페이스 멤버를 구현할 때만 위임을 쓸 수 있다.
예를 들어 위 코드에서 인터페이스인 PersonData 말고 클래스인 Person을 사용하면 오류가 발생한다.
class Alias( private val realIdentity: PersonData, private val newIdentity: PersonData ): Person by newIdentity // ERROR : Only interfaces can be delegated to
여기서 중요한 내용은 다음과 같다.
클래스 위임을 사용하면 번거로운 준비 코드를 사용하지 않고도
객체 합성(composition)과 상속의 이점을 살릴 수 있다.
따라서 코틀린의 위임은 상속보다는 합성(composition over inheritance)이라는
잘 알려진 객체지향 설계 원칙을 사용하도록 장려한다.
8장은 코틀린 타입 시스템이 제공하는 강력한 상속 매커니즘에 대한 내용이다.
개인적으로 아예 개념만 있는 단원이라 이해하는데 시간이 꽤 오래 걸리기도 했고 다 이해를 하지 못한 내용 역시 존재한다.
다음 장은 제네릭스이다. 역시 어려워보이고 분량도 많아보인다.
날도 춥고 슬슬 지치는데, 그래도 꾸준히 작성해봐야겠다.
반응형'Study(종료) > Kotlin 22.09.13 ~ 12.18' 카테고리의 다른 글
9-2. 제네릭스 : 변성 (Variance) (0) 2022.11.13 9-1. 제네릭스 : 타입 파라미터 (0) 2022.11.10 8-1. 클래스 계층 이해하기 : 상속 (0) 2022.11.05 7-2 컬렉션과 I/O 자세히 알아보기 : 파일과 I/O 스트림 (0) 2022.10.30 7-1 컬렉션과 I/O 자세히 알아보기 : 컬렉션 타입(3) (0) 2022.10.30