2021. 11. 3. 12:57ㆍKotlin
지난 글(Kotlin 함수 정의와 호출)에 이어 Kotlin in Action 이라는 책을 보면서 매주 공부한 내용을 블로그에 기록한다.
이번 주는 4장 클래스/객체/인터페이스 에 대한 내용이다. 책 내용은 어마어마하게 많기 때문에.. 내 기준으로 새로 알게 된 것들, 평소에는 모르고 지나쳤던 부분 또는 한번 짚고 넘어가면 좋은 내용들 위주로 정리한다.
목차
Interface
코틀린 인터페이스 특징
- 자바와 다르게, 추상 메소드 뿐만 아니라 구현이 있는 메소드도 정의할 수 있다.
- 단, 인터페이스에는 상태를 저장할 수 없다. 즉, 프로퍼티에 값이 저장될 수 없다.
- 그러나, 프로퍼티에 게터를 구현할 수는 있다. 이는 상태를 저장하는 것이 아닌, 해당 프로퍼티가 호출될 때마다 호출되는 게터 함수이기 때문.
interface User {
val nickname: String
// 인터페이스는 상태를 저장할 수 없다.
val nicknameWithValue = "nickname" // compile error
// 아래처럼 사용은 가능. 상태를 저장하는 것이 아닌, nicknameWithGetter 가 호출될 때 마다 get()이 호출되기 때문.
val nicknameWithGetter: String
get() = "nickname"
}
💡 만약 클래스에서 여러 개의 인터페이스를 implement 할 경우, 동일한 메소드가 존재하면 어떻게 될까?
interface Clickable {
// 일반 메소드 선언
fun click()
// 디폴트 구현이 있는 메소드
fun showOff() = println("I'm clickable!")
}
interface Clickable {
// 일반 메소드 선언
fun click()
// 디폴트 구현이 있는 메소드
fun showOff() = println("I'm clickable!")
}
코틀린 컴파일러는 두 메소드를 아우르는 구현을 하위 클래스에 직접 구현하게 강제한다. 즉, 아래와 같이 구현하면 된다.
class Button : Clickable, Focusable {
override fun click() = println("I was clicked!")
/**
* 이름과 시그니처가 같은 멤버 메소드에 대해 둘 이상의 디폴트 구현이 있는 경우,
* 인터페이스를 구현하는 하위 클래스에 명시적으로 새로운 구현을 제공해야 한다.
*/
override fun showOff() {
// 꼭 아래와 같이 2개 모두 호출할 필요는 없다.
super<Clickable>.showOff()
super<Focusable>.showOff()
}
}
open, final, abstract 변경자
Effective Java 에서는 상속을 위한 설계와 문서를 갖추거나, 그럴 수 없다면 상속을 금지하라 라는 조언을 한다. 그 만큼 상속으로 인해 많은 문제가 생길 수 있다는 것.
코틀린 클래스와 메소드는 기본적으로 final이다. 상속을 허용하려면 클래스 또는 메소드 앞에 open 변경자를 붙여야 한다.
오버라이드한 메소드는 기본적으로 open 특징을 갖는다. 즉, 하위클래스에서 override 가능. 만약 오버라이드 한 메소드를 하위 클래스에서 오버라이드 하지 못하게 하려면, final 을 명시하면 된다.
Inner class(내부 클래스) vs Nested class(중첩 클래스)
자바처럼 코틀린에서도 클래스 안에 다른 클래스를 선언할 수 있다. 도우미 클래스를 캡슐화하거나 코드 정의를 그 코드를 사용하는 곳 가까이에 두고 싶을 때 유용하다.
내부에 추가한 클래스의 종류는 아래 2개로 나뉜다.
- Inner class (내부 클래스)
- Nested class (중첩 클래스)
위 2개의 차이점은? 안에 선언된 클래스가 바깥 클래스를 참조하냐의 여부가 다르다!
class B 안에 정의된 class A | 특징 | 자바에서의 사용법 | 코틀린에서의 사용법 |
Inner class (내부 클래스) | - 바깥쪽 클래스에 대한 참조를 저장함 - 즉, 내부 클래스에서 외부 클래스에 접근할 수 있다. |
class A | inner class A |
Nested class (중첩 클래스) | - 바깥쪽 클래스에 대한 참조를 저장하지 않음 - 즉, 내부 클래스에서 외부 클래스에 접근할 수 없음. |
static class A | class A |
class Outer {
private val outer = "outer"
// Nested class
class NestedInner {
// 바깥 클래스를 참조하기 "않기" 때문에, 바깥 함수의 [outer] 필드에 접근 불가! 즉, 아래 코드는 컴파일 오류 발생!
val inner = "${outer}-inner" // compile error
}
// Inner class
inner class Inner {
// 바깥 클래스를 참조하기 때문에, 바깥 함수의 [outer] 필드에 접근 가능
val inner = "${outer}-inner"
}
}
Class Property
Backing field
Backing field 가 뭘까? 나는 실제로 코드에서 backing field 를 쓰긴 했지만, backing field 란 용어와 이것의 정확한 의미 등은 이번 공부를 통해 처음 알게 됐다.
In Kotlin, a field is only used as a part of a property to hold its value in memory. Fields cannot be declared directly. However, when a property needs a backing field, Kotlin provides it automatically. This backing field can be referenced in the accessors using the field identifier:
A backing field will be generated for a property if it uses the default implementation of at least one of the accessors, or if a custom accessor references it through the field identifier.
- 공식문서
값을 저장하는 동시에 로직을 실행할 수 있게 하기 위해서는 접근자 안에서 프로퍼티를 backup 하는 필드에 접근할 수 있어야 한다.
접근자의 본문에서는 field 라는 특별한 식별자를 통해 backing field 에 접근할 수 있다. 게터에서는 field 값을 읽을 수만 있고, 세터에서는 field 값을 읽거나 쓸 수 있다.
class User(val name: String) {
var address: String = "unspecified"
set(value: String) {
println("""Address was changed for $name: $field -> $value""")
// 이 field 라는 식별자를 통해 backing field 에 접근.
field = value // backing field 변경하기
}
}
💡단, 모든 프로퍼티에 backing field 가 존재하는게 아니다!! 그럼, 어느 경우에 backing field 가 존재할까?
- 컴파일러는 디폴트 접근자 구현을 사용하건 직접 게터나 세터를 정의하건 관계없이 게터나 세터에서 field 를 사용하는 프로퍼티에 대해 backing field를 생성해준다.
- 다만, field 를 사용하지 않는 커스텀 접근자 구현을 정의한다면 backing field는 존재하지 않는다.
아래의 예시를 보자.
/**
* val 인 경우
*/
// backing field 없음. field 를 사용하지 않는 커스텀 게터를 구현했기 때문에.
val property1: Int
get() {
return 1
}
// compile error : Initializer is not allowed here because this property has no backing field
val property1: Int = 0
get() {
return 1
}
// 이건 성공
val property1: Int = 0
get() {
return field
}
/**
* var 인 경우
*/
// backing field 존재
var property1: Int = 0
get() {
return field + 1
}
// backing field 존재
var property1: Int = 3
set(value) {
field = value
}
get() {
return 1
}
// compile error: Property must be initialized
var property1: Int
get() {
return 1
}
// 이건 성공. backing field 없음.
var property1: Int
set(value) {
}
get() {
return 1
}
접근자의 visibility 변경
원한다면 get이나 set 앞에 visibility 변경자를 추가해서 접근자의 가시성을 변경할 수 있다.
var counter: Int = 0
private set // 이 클래스 밖에서 이 프로퍼티의 값을 바꿀 수 없다!
data class
어떤 클래스가 데이터를 저장하는 역할만을 수행한다면 toString, equals, hashCode 를 반드시 오버라이드 해야 한다. 코틀린에서는 data 라는 변경자를 클래스 앞에 붙이면 필요한 메소드를 컴파일러가 자동으로 만들어준다.
data class Client(val name: String, val postalCode: String)
- equals와 hashCode는 주 생성자에 나열된 모든 프로퍼티를 고려해 만들어진다.
- 생성된 equals 메소드는 모든 프로퍼티 값의 동등성을 확인한다.
- hashCode 메소드는 모든 프로퍼티의 해시 값을 바탕으로 계산한 해시 값을 반환한다.
- JVM 언어에서는 equals()가 true를 반환하는 두 객체는 반드시 같은 hashCode를 반환해야 한다 라는 제약이 있다.
데이터 클래스와 불변성: copy() 메소드
- 데이터 클래스의 프로퍼티를 읽기 전용(val 사용)으로 만들어서 immutable 클래스로 만들라고 권장
- HashMap 등의 컨테이너에 데이터 클래스 객체를 담는 경우엔 불변성이 필수적이다.
- 불변 객체를 주로 사용하는 프로그램에서는 스레드가 사용 중인 데이터를 다른 스레드가 변경할 수 없으므로 스레드를 동기화해야 할 필요가 줄어든다.
코틀린 컴파일러가 제공하는 copy() 메소드를 이용하면 데이터 클래스를 불변 객체로 더 쉽게 활용할 수 있다. 객체를 메모리상에서 직접 바꾸는 대신 복사본을 만드는 편이 더 낫다고 한다.
val lee = Client("이계영", 4122) println(lee.copy(postalCode = 4000)) // 출력: Client(name=이계영, postalCode=4000)
Class Delegate (클래스 위임)
대규모 객체지향 시스템을 설계할 때 시스템을 취약하게 만드는 문제는 보통 구현 상속에 의해 발생한다. 하위 클래스가 상위 클래스의 메소드 중 일부를 오버라이드 하면 하위 클래스는 상위 클래스의 세부 구현 사항에 의존하게 된다. 시스템이 변함에 따라 상위 클래스의 구현이 바뀌거나 상위 클래스에 새로운 메소드가 추가된다. 그 과정에서 하위 클래스가 상위 클래스에 대해 갖고 있던 가정이 깨져서 코드가 정상적으로 작동하지 못하는 경우가 생길 수 있다.
코틀린을 설계하면서 우리는 이런 문제를 인식하고 기본적으로 클래스를 final로 취급하기로 결정했다. 모든 클래스를 기본적으로 final로 취급하면 상속을 염두에 두고 open 변경자로 열어둔 클래스만 확장할 수 있다.
하지만 종종 상속을 허용하지 않는 클래스에 새로운 동작을 추가해야 할 때가 있다. 이럴 때 사용하는 일반적인 방법이 *데코레이터(Decorator) 패턴이다.
그런데 이런 접근 방법의 단점은 준비 코드가 상당히 많이 필요하다는 점.
그러나, 코틀린은 이런 위임을 언어가 제공하는 일급 시민 기능으로 지원한다는 점이 장점이다. 코틀린에서는 인터페이스를 구현할 때 by 키워드를 통해 그 인터페이스에 대한 구현을 다른 객체에 위임 중이라는 사실을 명시할 수 있다.
- 책에서
💡 Decorator Pattern
데코레이터 패턴은 특정 클래스의 기본 기능에 추가 기능을 기존 클래스를 수정하지 않고 패턴을 통하여 덧 붙이고 싶을 때 사용한다.
예시 :
- 개발자는 개발이라는 업무만 할 수 있다.
- 그런데 추가적으로 개발업무에 안드로이드개발 이라는 업무를 넣고 싶다.
- 하지만 개발업무만 하는 개발자가 필요할 수도 있기 때문에 필요할 때 만 안드로이드개발 이라는 업무를 추가하고 싶다.
companion object
코틀린 클래스 안에는 static 멤버가 없다. 그 대신 최상위 함수(자바의 static 메소드 역할을 거의 대신 할 수 있다)와 object 선언(자바의 static 메소드 역할 중 코틀린 최상위 함수가 대신할 수 없는 역할이나 static 필드를 대신할 수 있다)을 활용한다. 대부분의 경우 최상위 함수를 활용하는 편을 더 권장한다. 하지만, 최상위 함수는 private 으로 표시된 클래스 멤버에 접근할 수 없다. 그래서 클래스의 인스턴스와 관계없이 호출해야 하지만, 클래스 내부 정보에 접근해야 하는 함수가 필요할 때는 클래스에 중첩된 객체 선언의 멤버 함수로 정의해야 한다.
⇒ companion object 키워드를 통해 만들 수 있다.
companion object 는 자신을 둘러싼 클래스의 모든 private 멤버에 접근할 수 있다. 바깥쪽 클래스의 private 생성자도 호출 가능하다. 따라서, 이는 팩토리 패턴을 구현하기 가장 적합한 위치다.
팩토리 메소드 구현 예시
// primary constructor 를 비공개로 만든다
class User private constructor(val nickname: String) {
companion object {
// 팩토리 메소드
fun newSubscribeUser(email: String) =
User(email.substringBefore('@'))
fun newFacebookUser(accountId: Int) =
User(getFacebookName(accountId))
}
}
팩토리 메소드는 매우 유용하다.
- 위의 예제처럼 목적에 따라 팩토리 메소드 이름을 정할 수 있다.
- 팩토리 메소드가 선언된 클래스의 하위 클래스 객체를 반환할 수도 있다.
- 생성할 필요가 없는 객체를 생성하지 않을 수도 있다.
Summary
- 코틀린 인터페이스는 디폴트 구현을 포함할 수 있고, 프로퍼티도 포함 가능
- 모든 코틀린 선언은 기본적으로 final / public
- 상속 및 오버라이딩이 가능하게 하려면 open 키워드 사용
- internal 선언은 같은 모듈 안에서만 볼 수 있다
- field 식별자를 통해 프로퍼티 접근자 안에서 데이터를 저장하는 데 쓰이는 backing field 를 참조할 수 있다
- data class 는 컴파일러가 equals/hashCode/toString/copy 등의 메소드를 자동으로 생성해준다
- class delegate 를 사용하면 위임 패턴을 구현할 때 필요한 수많은 준비 코드를 줄일 수 있다
- object 선언을 통해 싱글톤 클래스를 정의할 수 있다
- companion object 는 자바의 static 메소드와 필드를 대신한다
- object, companion object 모두 인터페이스를 구현할 수 있다
'Kotlin' 카테고리의 다른 글
[Kotlin in Action] 9장. 제네릭스(Generics) - reified / variance / in / out (0) | 2022.01.30 |
---|---|
[Kotlin in Action] 8장. 고차함수와 inline function (inline 함수의 장단점, 사용 이유 등) (0) | 2022.01.09 |
[Kotlin in Action] 3장. 함수 정의와 호출 (0) | 2021.10.09 |
[Kotlin] static, object, companion object 차이 (1) | 2021.06.27 |
[Kotlin] Coroutine suspend function 은 대체 뭐야? (16) | 2021.06.13 |