코틀린3 - 클래스, 객체, 인터페이스
4장. 클래스, 객체, 인터페이스
4.1 클래스 계층 정의
4.1.1 코틀린 인터페이스
코틀린 인터페이스 안에는 추상 메소드뿐 아니라 구현이 있는 메소드도 정의할 수 있다. 다만 인터페이스에는 아무런 필드도 들어갈 수 없다.
interface Clickable {
fun click()
}
이 인터페이스를 구현하려면
class Button:Clickable{
override fun click() = println("I was clicked")
}
- override 변경자 반드시 필요
- 디폴트 구현할 때 특별한 키워드 필요없이 그냥 구현하면 됨
interface Clickable{
fun click() // 추상 메소드
fun showOff() = println("I'm clickable!") // 디폴트 구현 메소드
}
다른 인터페이스가 showOff 메소드를 함께 구현하면??
interface Focusable{
fun setFocus(b:Boolean) = println("I ${if (b) "got" else "lost"} focus.")
fun showOff() = println("I'm focusable!")
}
어느 쪽도 선택되지 않는다. 클래스가 구현하는 두 상위 인터페이스에 정의된 showOff구현을 대체할 오버라이딩 메소드를 직접 제공하지 않으면 다음과 같은 컴파일러 오류가 발생한다.
The class 'Button' must override public open fun showOff() because it inherits many implementations of it.
코틀린 컴파일러는 두 메소드를 아우르는 구현을 하위 클래스에 직접 구현하게 강제한다.
상속한 인터페이스의 메소드 구현 호출하기
class Button:Clickable, Focusable{
override fun click() = println("I was clicked")
override fun showOff(){
super<Clickable>.showOff()
// 이름과 시그니처가 같은 멤버 메소드에 대해 둘 이상의 디폴트 구현이 있는 경우 인터페이스를 구현하는 하위 클래스에서 명시적으로 새로운 구현을 제공해야 한다.
super<Focusable>.showOff()
// 상위 타입의 이름을 꺾쇠 괄호 사이에 넣어서 super를 지정하면 어떤 상위 타입의 멤버 메소드를 호출할지 지정할 수 있다.
}
}
이 클래스의 인스턴스를 만들고 showOff()가 구현대로 상속한 모든 메소드를 호출하는지 검증해볼 수 있다.
fun main(args:Array<String>){
val button = Button()
button.showOff()
// I'm clickable!
// I'm focusable! 출력
button.setFocus(true) // I got focus. 출력
button.click() // I was clicked. 출력
}
Button 클래스는 Focusable 인터페이스 안에 선언된 setFocus의 구현을 자동으로 상속한다.
자바에서 코틀린의 메소드가 있는 인터페이스 구현하기 코틀린은 자바 6와 호환되게 설계됐다. 자바의 디폴트 메소드는 자바 8 이상부터 지원하기 때문에 코틀린은 디폴트 메소드가 있는 인터페이스를 일반 인터페이스와 디폴트 메소드 구현이 정적 메소드로 들어있는 클래스를 조합해 구현한다.
4.1.2 open, final, abstract 변경자 : 기본은 final
어떤 클래스가 자신을 상속하는 방법에 대해 정확한 규칙(어떤 메소드를 어떻게 오버라이드해야 하는지 등)을 제공하지 않는다면 그 클래스의 클라이언트는 기반 클래스를 작성한 사람의 의도와 다른 방식으로 메소드를 오버라이드할 위험이 있다. 모든 하위 클래스를 분석하는 것은 불가능하므로 기반클래스를 변경하는 경우 하위 클래스의 동작이 예기치 않게 바뀔 수도 있다는 면에서 기반 클래스는 취약하다.
Effective Java - 상속을 위한 설계와 문서를 갖추거나, 그럴 수 없다면 상속을 금지하라 -> 특별히 오버라이드하게 의도된 클래스와 메소드가 아니라면 모두 final로 만들라는 뜻.
코틀린의 클래스와 메소드는 기본적으로 final이다. 어떤 클래스의 상속을 허용하려면 클래스 앞에 open 변경자를 붙여야 한다. 또한 오버라이드를 허용하고 싶은 메소드나 프로퍼티의 앞에도 open 변경자를 붙여야 한다.
open class RichButton:Clickable{ // 상속가능한 클래스
fun disable(){} // final -> override 불가능한 메소드
open fun animate(){} // override 가능한 메소드
override fun click(){} // 상위클래스에서 열려있는 메소드를 오버라이드한 메소드 -> 기본적으로 open되어 있기 때문에 하위 클래스에서 이 메소드를 오버라이드 할 수 있다.
final override fun showOff(){} // 상위클래스에서 열려있는 메소드를 오버라이드한 메소드. final이기 때문에 하위클래스에서 이 메소드를 오버라이드 할 수는 없다.
}
추상 클래스 정의하기
abstract class Animated{ // 이 클래스의 인스턴스를 만들 수 없음
abstract fun animate() // 하위 클래스에서 반드시 오버라이드 해야함
open fun stopAnimating(){ ... }
fun animateTwice(){ ... }
// 추상 클래스에 속했더라도 비추상 함수는 기본적으로 final이지만 원한다면 open으로 오버라이드를 허용할 수 있다.
}
- access modifier (상속 제어 변경자) -> final, open, abstract, override
4.1.3 가시성 변경자 : 기본은 public
visibility modifier
변경자 | 클래스 멤버 | 최상위 선언 |
---|---|---|
public(기본) | 모든 곳 | 모든 곳 |
internal | 같은 모듈 내 | 같은 모듈 내 |
protected | 하위 클래스 내 | x |
private | 같은 클래스 내 | 같은 파일 내 |
internal open class TalkativeButton:Focusable{
private fun yell() = println("Hey!")
protected fun whisper() = println("Let's talk!")
}
fun TalkativeButton.giveSpeech(){
yell()
whisper()
}
위 코드에서 잘못된 점을 모두 찾아보자
4.1.4 내부 클래스와 중첩된 클래스 : 기본은 중첩 클래스
nested class 클래스 안에 다른 클래스 선언 가능. 코틀린 중첩 클래스는 명시적으로 요청하지 않는 한 바깥쪽 클래스 인스턴스에 대한 접근 권한이 없다.
interface State: Serializable
interface View{
fun getCurrentState():State
fun restoreState(state:State){}
}
버튼 클래스의 상태를 저장하는 클래스는 버튼 클래스 내부에 선언하면 편하다.
class Button:View{
override fun getCurrentState():State = ButtonState()
override fun restoreState(state:State) { ... }
class ButtonState:State{ ... } // 자바의 정적 중첩 클래스와 대응
}
내부클래스 Inner 안에서 바깥쪽 클래스 Outer의 참조에 접근하려면 this@Outer라고 써야 한다.
class Outer{
inner class Inner{
fun getOuterReference():Outer = this@Outer
}
}
중첩 클래스를 유용하게 사용하는 사례 -> 클래스 계층을 만들되 그 계층에 속한 클래스의 수를 제한하고 싶은 경우.
4.1.5 sealed 클래스 : 클래스 계층 정의 시 계층 확장 제한
상위 클래스 Expr -> 하위 클래스 Num, Sum when 식에서 모든 하위 클래스를 처리할 때 else 분기가 반드시 필요
interface Expr
class Num(val value:Int):Expr
class Sum(val left:Expr, val right:Expr):Expr
fun eval(e:Expr):Int = when(e){
is Num -> e.value
is Sum -> eval(e.right) + eval(e.left)
else -> throw IllegalArgumentException("Unknown expression") // 반드시 필요
}
그리고 디폴트 분기가 있으면 이런 클래스 계층에 새로운 하위 클래스를 추가하더라도 컴파일러가 when이 모든 경우를 처리하는지 제대로 검사할 수 없다. 실수로 새로운 클래스 처리를 잊어버리면 else 분기가 선택되기 때문에 심각한 버그가 발생한다.
해결책은 sealed 클래스. 상위 클래스에 sealed 변경자를 붙이면 그 상위 클래스를 상속한 하위 클래스 정의를 제한할 수 있다.
sealed class Expr{ // 기반 클래스를 sealed로 봉인
class Num(val value:Int):Expr() // 모든 하위 클래스를 중첩클래스로 나열
class Sum(val left:Expr, val right:Expr):Expr()
}
fun eval(e:Expr):Int = when(e){ // else 분기 필요 x
is Expr.Num -> e.value
is Expr.Sum -> eval(e.right) + eval(e.left)
}
sealed 클래스는 자동으로 open. 나중에 sealed 클래스의 상속 계층에 새로운 하위 클래스를 추가해도 when 식이 컴파일되지 않는다. 따라서 when 식을 고쳐야 한다는 사실을 쉽게 알 수 있다.
4.2 생성자와 프로퍼티
4.2.1 클래스 초기화: 주 생성자와 초기화 블록
class User constructor(_nickname:String){
val nickname:String
init {
nickname = _nickname
}
}
- 위의 클래스에서 nickname 프로퍼티를 초기화하는 코드를 nickname 프로퍼티 선언에 포함시킬 수 있어서 초기화 코드를 초기화 블록에 넣을 필요가 없다.(init 생략가능)
- 주 생성자 앞에 별다른 애노테이션이나 가시성 변경자가 없다면 constructor를 생략해도 된다.
이런 변경을 적용하고 나면
class User(_nickname:String){ // 파라미터가 하나뿐인 주 생성자
val nickname = _nickname // 프로퍼티를 주 생성자의 파라미터로 초기화
}
- 프로퍼티를 초기화하는 식이나 초기화 블록 안에서만 주 생성자의 파라미터를 참조할 수 있다.
- 주 생성자의 파라미터로 프로퍼티를 초기화한다면 그 주 생성자 파라미터 이름 앞에 val을 추가하는 방식으로 간략히 쓸 수 있다.
class User(val nickname:String)
- 함수 파라미터와 마찬가지로 생성자 파라미터에도 디폴트 값을 정의할 수 있다.
class User(val nickname:String, val isSubscribed:Boolean = true)
클래스의 인스턴스를 만들려면 new 키워드 없이 생성자를 직접 호출하면 된다.
val hyun = User("현석") // isSubscribed에 디폴트 값이 있으므로 두번째 인자는 optional
println(hyun.isSubscribed) // 디폴트 값 true로 초기화
클래스에 기반 클래스가 있다면 주 생성자에서 기반 클래스의 생성자를 호출해야 할 필요가 있다. 기반 클래스를 초기화하려면 기반 클래스 이름 뒤에 괄호를 치고 생성자 인자를 넘긴다.
open class User(val nickname:String) { ... }
class TwitterUser(nickname:String):User(nickname){ ... }
클래스를 정의할 때 별도로 생성자를 정의하지 않으면 컴파일러가 인자가 없는 디폴트 생성자를 만들어준다.
class RadioBUtton:Button()
Button의 생성자는 아무 인자도 받지 않지만, Button 클래스를 상속한 하위 클래스는 반드시 Button 클래스의 생성자를 호출해야 한다.
- 이 규칙으로 인해 기반 클래스의 이름 뒤에는 꼭 빈 괄호가 들어간다.
- 반면 인터페이스는 생성자가 없기 때문에 어떤 클래스가 인터페이스를 구현하는 경우 그 클래스의 상위 클래스 목록에 있는 인터페이스 이름 뒤에는 아무 괄호도 없다.
- 클래스 정의에 있는 상위 클래스 및 인터페이스 목록에서 이름 뒤에 괄호가 붙었는지 살펴보면 쉽게 기반 클래스와 인터페이스를 구별할 수 있다.
어떤 클래스를 클래스 외부에서 인스턴스화하지 못하게 막고 싶다면 모든 생성자를 private으로 만들면 된다.
class Secretive private constructor(){}
4.2.2 부 생성자: 상위 클래스를 다른 방식으로 초기화
자바에서 오버로드한 생성자가 필요한 상황 중 상당수는 코틀린의 디폴트 파라미터 값과 이름 붙은 인자 문법을 사용해 해결할 수 있다. 그래도 생성자가 여럿 필요한 경우가 가끔 있다. 프레임워크 클래스를 확장해야 하는데 여러 가지 방법으로 인스턴스를 초기화할 수 있게 다양한 생성자를 지원해야 하는 경우.
open class View{
constructor(ctx:Context){
//코드
}
constructor(ctx:Context, attr:AttributeSet){
//코드
}
}
이 클래스는 주 생성자를 선언하지 않고 부 생성자만 2가지 선언한다. 이 클래스를 확장하면서 똑같이 부 생성자를 정의할 수 있다.
class MyButton:View{
constructor(ctx:Context){
:super(ctx)
// ...
}
constructor(ctx:Context, attr:AttributeSet):super(ctx, attr){
// ...
}
}
}
super() 키워드를 통해 자신에 대응하는 상위 클래스 생성자를 호출한다.
class MyButton:View{
constructor(ctx:Context):this(ctx, MY_STYLE){ // 이 클래스의 다른 생성자에게 위임.
// ...
}
constructor(ctx:Context, attr:AttributeSet):super(ctx, attr){
// ...
}
}
자바와 마찬가지로 생성자에서 this()를 통해 클래스 자신의 다른 생성자를 호출할 수 있다.
클래스에 주 생성자가 없다면 모든 부 생성자는 반드시 상위 클래스를 초기화하거나 다른생성자에게 생성을 위임해야 한다.
4.2.3 인터페이스에 선언된 프로퍼티 구현
코틀린에서는 인터페이스에 추상 프로퍼티 선언을 넣을 수 있다.
interface User{
val nickname:String
}
이는 User 인터페이스를 구현하는 클래스가 nickname의 값을 얻을 수 있는 방법(생성자 프로퍼티, 커스텀 게터 or 프로퍼티 초기화 식)을 제공해야 한다는 뜻이다.
class PrivateUser(override val nickname:String):User // 주 생성자 프로퍼티
class SubscribingUser(val email:String):User{
override val nickname get() = email.substringBefore('@') // 커스텀 게터
}
class FacebookUser(val accountId:Int):User{
override val nickname = getFacebookName(accountId) // 프로퍼티 초기화 식
}
- PrivatgeUser -> User의 추상 프로퍼티 구현 -> override 표시
- SubscribingUser -> 커스텀 게터 -> 매번 이메일 주소에서 별명을 계산해 반환
- FacebookUser -> 초기화 식 -> 객체 초기화 단계에 한 번만 (페이스북에 인증 후 데이터를 가져와야 하기 때문에 비용이 많이 들 수 있기 때문)
인터페이스에는 추상 프로퍼티뿐 아니라 게터와 세터가 있는 프로퍼티를 선언할 수도 있다. 물론 그런 게터와 세터는 뒷받침하는 필드(Backing field)를 참조할 수 없다
interface User{
val email:String // 추상 프로퍼티
val nickname get() = email.substringBefore('@')
// 프로퍼티에 뒷받침하는 필드가 없다. 대신 매번 결과를 계산해 돌려준다.
}
- email -> 하위클래스가 반드시 오버라이드해야 함
- nickname -> 오버라이드 하지 않고 상속
4.2.4 게터와 세터에서 뒷받침하는 필드에 접근
- 프로퍼티에 저장된 값의 변경 이력을 로그에 남기려는 경우 변경 가능한 프로퍼티를 정의하되 세터에서 프로퍼티 값을 바꿀 때마다 약간의 코드를 추가로 실행해야한다.
class User(val name:String){
var address = "unspecified"
set(value:String){
println("""Address was changed for $name:
"$field" -> "$value".""".trimIndent()) // 뒷받침하는 필드 값 읽기
field = value // 뒷받침하는 필드 값 변경하기
}
}
val user = User("Alice")
user.address = "Elsenheimerstrasse 47, 80687 Muenchen"
//Address was changed for Alice:
//"unspecified" -> "Elsenheimerstrasse 47, 80687 Muenchen"
코틀린에서 프로퍼티의 값을 바꿀 때는 user.address = “new value”처럼 필드 설정 구문을 사용한다. 이 구문은 내부적으로는 address의 세터를 호출한다. 이 예제에서는 커스텀 세터를 정의해서 추가 로직을 실행한다. 접근자의 본문에서는 field라는 식별자를 통해 뒷받침하는 필드에 접근할 수 있다. 게터에서는 field 값을 읽을 수만 있고, 세터에서는 field 값을 읽거나 쓸 수 있다.
컴파일러는 디폴트 접근자 구현을 사용하건 직접 게터나 세터를 정의하건 관계없이 게터나 세터에서 field를 사용하는 프로퍼티에 대해 뒷받침하는 필드를 생성해준다. But field를 사용하지 않는 커스텀 접근자 구현을 정의한다면 뒷받침하는 필드는 존재하지 않는다.
4.2.5 접근자의 가시성 변경
class LengthCounter{
var counter:Int = 0
private set // 이 클래스 밖에서 이 프로퍼티의 값을 바꿀 수 없다.
fun addWord(word:String){
countr += word.length
}
}
이 클래스는 자신에게 추가된 모든 단어의 길이를 합산한다. 클라이언트에게 제공하는 API의 일부분이므로 public으로 외부에 공개된다. 하지만 외부 코드에서 단어 길이의 합을 마음대로 바꾸지 못하게 이 클래스 내부에서만 길이를 변경하게 만들고 싶다. 그래서 기본 가시성을 가진 게터를 컴파일러가 생성하게 내버려 두는 대신 세터의 가시성을 private로 지정한다.
4.3 데이터 클래스와 클래스 위임
자바 플랫폼에서는 클래스가 equals, hashCode, toString 등의 메소드를 구현해야 한다. 그리고 보통 비슷한 방식으로 기계적으로 구현할 수 있다. IDE가 자동으로 작성해주긴 하지만 코드베이스가 번잡해지는 것은 동일하다. 코틀린 컴파일러는 이런 메소드를 기계적으로 생성하는 작업을 보이지 않는 곳에서 해준다.
4.3.1 모든 클래스가 정의해야 하는 메소드
Client 클래스의 초기 정의
class Client(val name:String, val postalCode:Int)
- toString() - 문자열 표현
객체의 문자열 표현은 Client@5e90f23b4 같은 방식인데, 그다지 유용하지 않고 기본 구현을 바꾸려면 toString 메소드를 오버라이드해야 한다.
class Client(val name:String, val postalCode:Int){
override fun toString() = "Client (name=$name, postalCode=$postalCode)"
}
- equals() - 객체의 동등성
서로 다른 두 객체가 내부에 동일한 데이터를 포함하는 경우 그 둘을 동등한 객체로 간주해야 할 수도 있다. 코틀린에서 == 연산자는 참조 동일성을 검사하지 않고 객체의 동등성을 검사한다. 따라서 == 연산은 equals를 호출하는 식으로 컴파일된다.
class Client(val name:String, val postalCode:Int){
override fun equals(other:Any?):Boolean{
if(other == null || other !is Client) return false
return name == other.name && postalCode == other.postalCode
}
override fun toString() = "Client (name=$name, postalCode=$postalCode)"
}
- hashCode() - 해시 컨테이너
자바에서는 equals를 오버라이드할 때 반드시 hashCode도 함께 오버라이드해야 한다.
>>> val processed = hashSetOf(Client("오현석", 4122))
>>> println(processed.contains(Client("오현석", 4122)))
false
프로퍼티가 모두 일치하므로 새 인스턴스와 집합에 있는 기존 인스턴스는 동일하다. 따라서 true가 반환되리라 예상할 수 있다. 하지만 실제로는 false가 나온다.
이는 Client 클래스가 hashCode 메소드를 정의하지 않았기 때문이다.
- JVM 언어에서는 “equals()가 true를 반환하는 두 객체는 반드시 같은 hashCode()를 반환해야 한다”는 제약이 있는데 Client는 이를 어기고 있다.
- HashSet은 원소를 비교할 때 비용을 줄이기 위해 먼저 객체의 해시 코드를 비교하고 해시 코드가 같은 경우에만 실제 값을 비교한다.
- 따라서 위 예제의 두 Client 인스턴스는 해시 코드가 다르기 때문에 두 번째 인스턴스가 집합 안에 들어있지 않다고 판단한다.
이 문제를 고치려면 Client가 hashCode를 구현해야 한다.
class Client(val name:String, val postalCode:Int){
...
override fun hashCode() = name.hashCode * 31 + postalCode
}
4.3.2 데이터 클래스: 모든 클래스가 정의해야 하는 메소드 자동 생성
어떤 클래스가 데이터를 저장하는 역할만을 수행한다면(eg. VO, DTO) toString, equals, hashCode를 반드시 오버라이드해야 한다. 코틀린은 data라는 변경자를 클래스 앞에 붙이면 필요한 메소드를 컴파일러가 자동으로 만들어준다. data 변경자가 붙은 클래스를 데이터 클래스라고 부른다.
data class Client(val name:String, val postalCode:Int)
이제 Client 클래스는 자바에서 요구하는 모든 메소드를 포함한다.
- 인스턴스 비교를 위한 equals
- HashMap과 같은 해시 기반 컨테이너에서 키로 사용할 수 있는 hashCode
- 클래스의 각 필드를 선언 순서대로 표시하는 문자열 표현을 만들어주는 toString
- equals와 hashCode는 주 생성자에 나열된 모든 프로퍼티를 고려해 만들어진다.
- hashCode 메소드는 모든 프로퍼티의 해시 값을 바탕으로 계산한 해시 값을 반환하는데 이때 주 생성자 밖에 정의된 프로퍼티는 equals나 hashCode를 계산할 때 고려의 대상이 아니다.
데이터 클래스는 위의 세 메소드뿐 아니라 몇 가지 유용한 메소드를 더 생성해준다.
- copy() - 데이터 클래스와 불변성
데이터 클래스의 모든 프로퍼티를 읽기 전용으로 만들어서 데이터 클래스를 불변 클래스로 만들라고 권장한다. HashMap 등의 컨테이너에 데이터 클래스 객체를 담는 경우엔 불변성이 필수적이다. 불변 객체를 주로 사용하는 프로그램에서는 스레드가 사용 중인 데이터를 다른 스레드가 변경할 수 없으므로 스레드를 동기화해야할 필요가 줄어든다. 데이터 클래스 인스턴스를 불변 객체로 더 쉽게 활용할 수 있게 코틀린 컴파일러는 객체를 복사하면서 일부 프로퍼티를 바꿀 수 있게 해주는 copy 메소드를 제공한다. 객체를 메모리상에서 직접 바꾸는 대신 복사본을 만드는 편이 더 낫다.
- 복사본은 원본과 다른 생명주기를 가진다.
- 일부 프로퍼티 값을 바꾸거나 복사본을 제거해도 프로그램에서 원본을 참조하는 다른 부분에 전혀 영향을 끼치지 않는다.
class Client(val name:String, val postalCode:Int){
...
fun copy(name = this.name, postalCode = this.postalCode) = Client(name, postalCode)
}
copy 메소드 사용
>>> val lee = Client("이계영", 4122)
>>> println(lee.copy(postalCode = 4000))
Client(name=이계영, postalCode=4000)
4.3.3 클래스 위임: by 키워드 사용
대규모 객체지향 시스템을 설계할 때 시스템을 취약하게 만드는 문제는 보통 구현 상속(implementation inheritance)에 의해 발생한다. 하위 클래스가 상위 클래스의 메소드 중 일부를 오버라이드하면 하위 클래스는 상위 클래스의 세부 구현 사항에 의존하게 된다. 시스템이 변함에 따라 상위 클래스의 구현이 바뀌거나 상위 클래스에 새로운 메소드가 추가된다. 그 과정에서 하위 클래스가 상위 클래스에 대해 갖고 있던 가정이 깨져서 코드가 정상적으로 작동하지 못하는 경우가 생길 수 있다. 종종 상속을 허용하지 않는 클래스에 새로운 동작을 추가해야 할 때가 있다. 이럴 때 일반적으로 데코레이터 패턴을 사용한다. 이 패턴의 핵심은 상속을 허용하지 않는 기존 클래스 대신 사요할 수 있는 새로운 클래스(데코레이터)를 만들되 기존 클래스와 같은 인터페이스를 데코레이터가 제공하게 만들고, 기존 클래스를 데코레이터 내부에 필드로 유지하는 것이다. 이때 새로 정의해야 하는 기능은 데코레이터의 메소드에 새로 정의하고(물론 이때 기존 클래스의 메소드나 필드를 활용할 수도 있다) 기존 기능이 그대로 필요한 부분은 데코레이터의 메소드가 기존 클래스의 메소드에게 요청을 전달(forwarding)한다. 이런 접근 방법의 단점은 준비 코드가 상당히 많이 필요하다는 점이다. 예를 들어 Collection 같이 비교적 단순한 인터페이스를 구현하면서 아무 동작도 변경하지 않는 데코레이터를 만들 때조차도 다음과 같이 복잡한 코드를 작성해야 한다.
class DelegatingCollection<T>:Collection<T>{
private val innerList = arrayListOf<T>()
override val size get() = innerList.size
override fun isEmpty() = innerList.isEmpty()
override fun contains(element:T) = innerList.contains(element)
override fun iterator():Iterator<T> = innerList.iterator()
override fun containsAll(elements:Collection<T>) = innerList.containsAll(elements)
}
이런 위임을 언어가 제공하는 일급 시민 기능으로 지원한다는 점이 코틀린의 장점이다. 인터페이스를 구현할 때 by 키워드를 통해 그 인터페이스에 대한 구현을 다른 객체에 위임 중이라는 사실을 명시할 수 있다.
class DelegatingCollection<T>{
innerList:Collection<T> = ArrayList<T>()
}:Collection<T> by innerList {}
클래스 안에 있던 모든 메소드 정의가 없어졌다. 컴파일러가 전달 메소드를 자동으로 생성한다. 메소드 중 일부의 동작을 변경하고 싶은 경우 메소드를 오버라이드하면 컴파일러가 생성한 메소드 대신 오버라이드한 메소드가 쓰인다.
이 기법을 이용해서 원소를 추가하려고 시도한 횟수를 기록하는 컬렉션을 구현해보자. 예를 들어 중복을 제거하는 프로세스를 설계하는 중이라면 원소 추가 횟수를 기록하는 컬렉션을 통해 최종 컬렉션 크기와 원소 추가 시도 횟수 사이의 비율을 살펴봄으로써 중복 제거 프로세스의 효율성을 판단할 수 있다.
class CountingSet<T>(
val innerSet:MutableCollection<T> = HashSet<T>()
):MutableCollection<T> by innerSet {
var objectsAdded = 0
override fun add(element:T):Boolean{
objectsAdded++
return innerSet.add(element)
}
override fun addAll(c:Collection<T>):Boolean{
objectsAdded += c.size
return innerSet.addAll(c)
}
}
>>> val cset = CountingSet<Int>()
>>> cset.addAll(listOf(1, 1, 2))
>>> println("${cset.objectAdded} objects were added, ${cset.size} remain")
3 objects were added, 2 remain
중요 - CountingSet에 MutableCollection의 구현 방식에 대한 의존관계가 생기지 않는다.
4.4 object 키워드:클래스 선언과 인스턴스 생성
코틀린에서는 object 키워드를 다양한 상황에서 사용하지만 모든 경우 클래스를 정의하면서 동시에 인스턴스를 생성한다는 공통점이 있다.
4.4.1 객체 선언: 싱글턴을 쉽게 만들기
인스턴스가 하나만 필요한 클래스
object Payroll{
val allEmployees = arrayListOf<Person>()
fun calculateSalary(){
for(person in allEmployees){
...
}
}
}
- 객체 선언은 object 키워드로 시작한다.
- 객체 선언은 클래스를 정의하고 그 클래스의 인스턴스를 만들어서 변수에 저장하는 모든 작업을 단 한문장으로 처리한다.
- 클래스와 마찬가지로 객체 선언 안에도 프로퍼티, 메소드, 초기화 블록 등이 들어갈 수 있다.
- 하지만 생성자는 쓸 수 없다.
- 일반 클래스 인스턴스와 달리 싱글턴 객체는 객체 선언문이 있는 위치에서 생성자 호출 없이 즉시 만들어진다.
- 변수와 마찬가지로 객체 선언에 사용한 이름 뒤에 마침표를 붙이면 객체에 속한 메소드나 프로퍼티에 접근할 수 있다.
- 객체 선언도 클래스나 인터페이스를 상속할 수 있다.
Payroll.allEmployees.add(Person(...))
Payroll.calculateSalary()
객체 선언을 사용해 Comparator 구현하기
// 두 파일 경로를 대소문자 관계없이 비교
object CaseInsensitiveFileComparator:Comparator<File>{
override fun compare(file1:File, file2:File):Int{
retur file1.path.compareTo(file2.path, ignoreCase = true)
}
}
>>> println(CaseInsensitiveFileComparator.compare(File("/User"), File("/user")))
일반 객체(클래스 인스턴스)를 사용할 수 있는 곳에서는 항상 싱글턴 객체를 사용할 수 있다. 예를 들어 이 객체를 Comparator를 인자로 받는 함수에게 인자로 넘길 수 있다.
>>> val files = listOf(File("/Z"), File("/a"))
>>> println(files.sortedWith(CaseInsensitiveFileComparator))
[/a, /Z]
클래스 안에서 객체를 선언할 수도 있다. 그런 객체도 인스턴스는 단 하나뿐이다. 바끝 클래스의 인스턴스마다 중첩 객체 선언에 해당하는 인스턴스가 하나씩 따로 생기는 것이 아니다.
4.4.2 동반 객체: 팩토리 메소드와 정적 멤버가 들어갈 장소
- 코틀린 클래스 안에는 정적인 멤버가 없다.
- 코틀린 언어는 자바 static 키워드를 지원하지 않는다.
- 대신 패키지 수준의 최상위 함수(자바의 정적 메소드 역할을 거의 대신 할 수 있다)
- 객체 선언(자바의 정적 메소드 역할 중 코틀린 최상위 함수가 대신할 수 없는 역할이나 정적 필드를 대신할 수 있다)을 활용한다.
- 대부분의 경우 최상위 함수를 활용하는 편을 더 권장한다.
- 하지만 최상위 함수는 private으로 표시된 클래스 비공개 멤버에 접근할 수 없다.
- 그래서 클래스 내부 정보에 접근해야 하는 함수가 필요할 때는 클래스에 중첩된 객체 선언의 멤버 함수로 정의해야 한다. (eg. 팩토리 메소드)
- 클래스 안에 정의된 객체 중 하나에 companion이라는 특별한 표시를 붙이면 그 클래스의 동반 객체로 만들 수 있다.
- 동반 객체의 프로퍼티나 메소드에 접근하려면 그 동반 객체가 정의된 클래스 이름을 사용한다. 이때 객체의 이름을 따로 지정할 필요가 없다.
- 그 결과 동반 객체의 멤버를 사용하는 구문은 자바의 정적 메소드 호출이나 정적 필드 사용 구문과 같아진다.
class A{
companion object{
fun bar(){
println("Companion object called")
}
}
}
>>> A.bar() // 인스턴스 생성하지 않고 호출
Companion object called
- 동반 객체가 private 생성자를 호출하기 좋은 위치다.
- 동반 객체는 자신을 둘러싼 클래스에 모든 private 멤버에 접근할 수 있다. 따라서 동반 객체는 바깥쪽 클래스의 private 생성자도 호출할 수 있다.
- 따라서 동반 객체는 팩토리 패턴을 구현하기 가장 적합한 위치다.
부 생성자 2개 있는 클래스 -> 동반 객체 안에서 팩토리 클래스를 정의하는 방식으로 변경해보기
class User{
val nickname:String
constructor(email:String){
nickname=email.substringBefore('@')
}
constructor(facebookAccountId:Int){
nickname=getFacebookName(facebookAccountId)
}
}
class User private constructor(val nickname:String){ // 주생성자 비공개
companion object{ // 동반 객체 선언
fun newSubscribingUser(email:String) = User(email.substringBefore('@'))
fun newFacebookUser(accountId:Int) = User(getFacebookName(accountId)) // 페이스북 사용자 ID로 사용자를 만드는 팩토리 메소드
}
}
클래스 이름을 사용해 그 클래스에 속한 동반 객체의 메소드를 호출할 수 있다.
>>> val subscribingUser = User.newSubscribingUser("bob@gmail.com")
>>> val facebookuser = User.newFacebookUser(4)
>>> println(subscribingUser.nickname)
bob
팩토리 메소드 장점
- 목적에 따라 팩토리 메소드 이름을 정할 수 있다.
- 팩토리 메소드가 선언된 클래스의 하위 클래스 객체를 반환할 수도 있다.
- 생성할 필요가 없는 객체를 생성하지 않을 수도 있다. 이메일 주소별로 유일한 User 인스턴스를 만드는 경우 이미 존재하는 인스턴스에 해당하는 이메일 주소를 전달받으면 새 인스턴스를 만들지 않고 캐시에 있는 기존 인스턴스를 반환할 수 있다.
단점
- 클래스를 확장해야만 하는 경우에는 동반 객체 멤버를 하위 클래스에서 오버라이드 할 수 없다. 여러 생성자를 사용하는 편이 더 낫다.
4.4.3 동반 객체를 일반 객체처럼 사용
동반 객체는 클래스 안에 정의된 일반 객체다. 따라서
- 동반 객체에 이름 붙일 수 있다.
- 인터페이스 상속, 동반 객체 안에 확장 함수와 프로퍼티를 정의할 수 있다.
class Person(val name:String){
companion object Loader{
fun fromJSON(jsonText:String):Person = ...
}
}
>>> person = Person.Loader.fromJSON("{name: 'Dmitry'}")
>>> person.name
Dmitry
>>> person2 = Person.fromJSON("{name: 'Brent'}")
>>> person2.name
Brent
특별히 이름을 지정하지 않으면 동반 객체 이름은 자동으로 Companion이 된다.
동반 객체에서 인터페이스 구현
시스템에 Person을 포함한 다양한 타입의 객체가 있다고 가정하자. 이 시스템에서는 모든 객체를 역직렬화를 통해 만들어야 하기 때문에 모든 타입의 객체를 생성하는 일반적인 방법이 필요하다. 이를 위해 JSON을 역직력화하는 JSONFactory 인터페이스가 존재한다. Person은 다음과 같이 JSONFactory 구현을 제공할 수 있다.
interface JSONFactory<T>{
fun fromJSON(jsonText:String):T
}
class person(val name:String){
companion object:JSONFactory<Person>{
override fun fromJSON(jsonText:String):Person = ... // 동반 객체가 인터페이스를 구현한다.
}
}
이제 JSON으로부터 각 원소를 다시 만들어내는 추상 팩토리가 있다면 Person 객체를 그 팩토리에게 넘길 수 있다.
fun loadFromJSON<T>(factory:JSONFactory<T>):T{
...
}
loadFromJSON(Person) // 동반 객체의 인스턴스를 함수에 넘긴다.
여기서 동반객체가 구현한 JSONFactory의 인스턴스를 넘길 때 Person 클래스의 이름을 사용했다는 점에 유의하라.
코틀린 동반 객체와 정적 멤버
클래스의 동반 객체는 일반 객체와 비슷한 방식으로, 클래스에 정의된 인스턴스를 가리키는 정적필드로 컴파일된다. 때로 자바에서 사용하기 위해 코틀린 클래스의 멤버를 정적인 멤버로 만들어야 할 필요가 있다. 그런 경우 @JvmStatic 애노테이션을 코틀린 멤버에 붙이면 된다. 정적 필드가 필요하다면 @JvmField 애노테이션을 최상위 프로퍼티나 객체에서 선언된 프로퍼티 앞에 붙인다.
동반 객체 확장
- 확장 함수를 사용하면 다른 곳에서 정의된 클래스의 인스턴스에 대해 새로운 메소드를 정의할 수 있었다.
- 자바의 정적 메소드나 코틀린의 동반 객체 메소드처럼 기존 클래스에 대해 호출할 수 있는 새로운 함수를 정의하고 싶다면 어떻게 해야 할까?
- 클래스에 동반 객체가 있으면 그 객체 안에 함수를 정의함으로써 클래스에 대해 호출할 수 있는 확장 함수를 만들 수 있다.
- C라는 클래스 안에 동반 객체가 있고 그 동반 객체(C.companion) 안에 func를 정의하면 외부에서는 func()를 C.func()로 호출할 수 있다.
동반 객체에 대한 확장 함수 정의하기
// 비즈니스 로직 모듈
class Person(val firstName:String, val lastName:String){
companion object{} //비어있는 동반 객체 선언
}
// 클라이언트 / 서버 통신 모듈
fun Person.Companion.fromJSON(json:String):Person{
// 확장함수 선언
}
val p = Person.fromJSON(json)
마치 동반 객체 안에서 fromJSON 함수를 정의한 것처럼 fromJSON을 호출할 수 있다. 하지만 실제로 fromJSON은 클래스 밖에서 정의한 확장 함수다. 클래스 멤버 함수처럼 보이지만, 실제로는 멤버 함수가 아니다. 동반 객체에 대한 확장 함수를 작성할 수 있으려면 원래 클래스에 동반 객체를 꼭 선언해야 한다. 빈 객체라도 동반 객체가 꼭 있어야 한다.
4.4.4 객체 식: 무명 내부 클래스를 다른 방식으로 작성
무명 객체는 자바의 무명 내부 클래스를 대신한다.
무명 객체로 이벤트 리스너 구현하기.
window.addMouseListener(
object:MouseAdapter(){ // MouseASdapter를 확장 하는 무명 객체 선언
override fun mouseClicked(e:MouseEvent){ // MouseAdapter의 메소드를 오버라이드
// ...
}
override fun mouseEntered(e:MouseEvent){
// ...
}
}
)
- 객체에 이름이 빠졌다.
- 객체 식은 클래스를 정의하고 그 클래스에 속한 인스턴스를 생성하지만, 클래스나 인스턴스에 이름을 붙이지는 않는다.
- 이런 경우 보통 함수를 호출하면서 인자로 무명 객체를 넘기기 때문에 클래스와 인스턴스 모두 이름이 필요하지 않다.
- 하지만 객체에 이름을 붙여야 한다면 변수에 무명 객체를 대입하면 된다.
val listener = object:MouseAdapter(){
override fun mouseClicked(e:MouseEvent){ ... }
override fun mouseEntered(e:MouseEvent){ ... }
}
- 객체 선언과 달리 무명 객체는 싱글턴이 아니다. 객체 식이 쓰일 때마다 새로운 인스턴스가 생성된다.
- 코틀린 무명 클래스는 자바와 달리 여러 인터페이스를 구현하거나 클래스를 확장하면서 인터페이스를 구현할 수 있다.
- final이 아닌 변수도 객체 식 안에서 사용할 수 있다.
무명 객체 안에서 로컬 변수 사용하기
fun countClicks(window:Window){
var clickCount = 0 // 로컬 변수를 정의한다.
window.addMouseListener(object:MouseAdapter(){
override fun mouseClicked(e:MouseEvent){
clickCount++ // 로컬 변수의 값을 변경한다.
}
})
}
- 객체 식은 무명 객체 안에서 여러 메소드를 오버라이드해야 하는 경우에 훨씬 더 유용하다. 메소드가 하나뿐인 인터페이스를 구현해야 한다면 코틀린의 SAM 변환(함수 리터럴을 변환해 SAM으로 만듦) 지원을 활용하는 편이 낫다. SAM 변환을 사용하려면 무명 객체 대신 함수리터럴(람다)을 사용해야 한다.
- SAM - Single Abstract Method 추상 메소드가 하나만 있는 인터페이스
요약
- 코틀린의 인터페이스는 디폴트 구현을 포함(자바8이상)할 수 있고 프로퍼티도 포함할 수 있다(자바 불가능).
- 모든 코틀린 선언은 기본적으로 final이며 public이다.
- 선언이 final이 되지 않게 (상속과 오버라이딩 가능하게) 하려면 앞에 open을 붙인다.
- internal 선언은 같은 모듈 안에서만 볼 수 있다.
- 중첩 클래스는 기본적으로 내부 클래스가 아니다. 바깥쪽 클래스에 대한 참조를 중첩 클래스 안에 포함시키려면 inner 키워드를 중첩 클래스 선언 앞에 붙여서 내부 클래스로 만들어야 한다.
- sealed 클래스를 상속하는 클래스를 정의하려면 반드시 부모 클래스 정의 안에 중첩 또는 내부 클래스로 정의해야 한다(코틀린 1.1부터는 같은 파일 안에만 있으면 된다).
- 초기화 블록과 부 생성자를 활용해 클래스 인스턴스를 더 유연하게 초기화할 수 있다.
- field 식별자를 통해 프로퍼티 접근자(getter, setter) 안에서 프로퍼티의 데이터를 저장하는 데 쓰이는 뒷받침하는 필드를 참조할 수 있다.
- 데이터 클래스를 사용하면 컴파일러가 equals, hashCode, toString, copy 등의 메소드를 자동으로 생성해준다.
- 클래스 위임을 사용하면 위임 패턴을 구현할 때 필요한 수많은 준비 코드를 줄일 수 있다.
- 객체 선언을 사용하면 코틀린답게 싱글턴 클래스를 정의할 수 있다.
- (패키지 수준 함수와 프로퍼티 및 동반 객체와 더불어) 동반 객체는 자바의 정적 메소드와 필드 정의를 대신한다.
- 동반 객체도 다른 (싱글턴) 객체와 마찬가지로 인터페이스를 구현할 수 있다. 외부에서 동반 객체에 대한 확장 함수와 프로퍼티를 정의할 수 있다.
- 코틀린의 객체 식은 자바의 무명 내부 클래스를 대신한다. 하지만 코틀린 객체식은 여러 인스턴스를 구현하거나 객체가 포함된 영역에 있는 변수의 값을 변경할 수 있는 등 자바 무명 내부 클래스보다 더 많은 기능을 제공한다.
참고자료
- Kotlin in Action
- http://try.kotl.in/
- https://kotlinlang.org/docs/tutorials/