Kotlin의 클래스, 객체, 그리고 인터페이스 한 번에 알아보기

Kotlin in Action Chapter 4 정리

Sigrid Jin
42 min readJan 2, 2022

Interface

interface Clickable {
fun click()
}
class Button: Clickable {
override fun click() = println("I am clicked!")
}
  • 클래스 이름 뒤에 상속할 클래스 또는 구현할 인터페이스를 지정
  • Java와 동일하게 여러 개의 인터페이스 구현 가능하지만, 하나의 클래스만 상속 가능
  • override 수식어: 인터페이스나 상위 클래스의 메소드, 프로퍼티를 재정의할 때 꼭 사용해야 함
interface Clickable {
companion object{
val prop = "나는 인터페이스 내의 Companion object의 속성이다."
fun method() = "나는 인터페이스 내의 Companion object의 메소드다."
}
fun click()
fun showOff() = println("I am clickable")
}
  • Default 구현 가능하다. 메소드 본문을 메소드 시그니처 뒤에 추가한다.
  • 인터페이스 내부에서 companion object를 구현할 수 있다. 덕분에 인터페이스 수준에서 상수항을 정의할 수 있고, 관련된 중요 로직을 이 곳에 기술할 수 있다.
interface Clickable {
fun click()
fun showOff() = println("I am clickable")
}
interface Focusable {
fun showOff() = println("I am focusable")
}
class Button: Clickable, Focusable {
override fun click() = println("I was clicked")
// 애매모호함을 없애기 위해 재정의함
override fun showOff() {
// super<타입>으로 사용할 상위 타입 지정
super<Clickable>.showOff()
}
}
  • 코틀린의 디폴트 메소드는 자바의 정적 메소드와 호환되며, 이는 자바 1.6과 호환된다.
  • 애매모호함을 없애지 않으면 컴파일러가 오류를 일으킨다.

Open, Final and Abstract 제한자

  • fragile base class 문제를 막기 위하여 Effective Java에서는 override의 의도가 없을 경우 무조건 final 키워드를 붙이라는 조언을 했음
  • Kotlin에서 final은 디폴트 접근자임. 즉, 클래스와 클래스의 멤버는 기본적으로 final
  • 상속을 허용하려면 클래스 앞에 open 수식어를 붙여야 함
  • 오버라이딩을 허용하고 싶은 메소드나 프로퍼티 앞에도 open 수식어를 붙여야 함
  • 클래스를 abstract로 선언하면 추상 클래스
  • Java 8과 마찬가지로 추상 클래스는 인스턴스화 할 수 없음
  • 추상 멤버는 항상 open임. (그러므로 추상 멤버 앞에는 open을 붙일 필요 X)

Example

// https://velog.io/@conatuseus/Kotlin-%ED%82%A4%EC%9B%8C%EB%93%9C-%EC%A0%95%EB%A6%AC-open-internal-companion-data-class-%EC%9E%91%EC%84%B1%EC%A4%91open class Car {    // 이 메서드는 하위 클래스에서 override 불가능
fun getNumberOfTires(): Int {
return 4
}

// 하위 클래스에서 override 가능
open fun hasSunRoof() :Boolean {
return false
}
}
// open 클래스는 상속이 가능하다!
class Benz() : Car() {
// getNumberOfTires 메서드는 override 불가능
// hasSunRoof 메서드는 open변경자가 붙어서 override 가능
override fun hasSunRoof(): Boolean {
return true
}
}
abstract class Animal { // 추상 메서드는 반드시 override 해야 함
abstract fun bark()
// 이 메서드는 하위 클래스에서 선택적으로 override 할 수 있다. (하거나 안하거나 자유)
open fun running() {
println("animal running!")
}
}
class Dog() : Animal() { override fun bark() {
println("멍멍")
}
// 이 메서드는 override 하거나 하지 않거나 자유.
override fun running() {
println("dog's running!")
}
}

Visibility Modifier

  • 코틀린에서는 아무 변경자도 없는 경우 선언은 모두 공개(public)됩니다.

The default visibility in Java, package-private, isn’t present in Kotlin.

Kotlin uses packages only as a way of organizing code in namespaces; it doesn’t use them for visibility control.

As an alternative, Kotlin offers a new visibility modifier, internal, which means “visible inside a module.”

A module is a set of Kotlin files compiled together. It may be an IntelliJ IDEA module, an Eclipse project, a Maven or Gradle project, or a set of files compiled with an invocation of the Ant task.

  • DDD 스럽쥬? (모듈 별로 계층을 만드는 hexagonal architecture에 알맞음)
  • Tip) kotlin에서는 접근할 수 없는 internal 코드를 Java 코드에서는 접근할 수 있다.
  • 예를 들어 다른 모듈에 정의된 internal 클래스나 internal 최상위 선언을 모듈 외부의 자바 코드에서 접근 할 수 있다.
  • 또한 코틀린에서 protected로 정의한 멤버를 코틀린 클래스나 같은 패키지에 속한 자바 코드에서 접근할 수 있다.
  • 접근은 가능하지만, 이름이 보기 불편하고 코드의 형상이 어색하게 뀌게됩니다. 가령 action()같은 이름의 함수라면 action$AAA_XXX_BBB() 같이 컴파일 됩니다.
  • 이름을 바꾸는 이유는 우연히 상위 클래스의 internal 메소드와 이름이 중복되어 override되는 것을 방지하고 internal 클래스를 외부에서 사용하는 것 을 방지하기 위한 장치로 생각 할 수 있다.
  • https://mond-al.github.io/kotlin-visibility-internal
  • https://stackoverflow.com/questions/45393423/kotlin-internal-classes-in-java-visible-publicly

Nested & Inner class

class Button: View {
override fun getCurrentState(): State = ButtonState()
override fun restoreState(state: State) { ... }

class ButtonState: State { ... } // 자바의 정적 중첩 클래스에 대응
}
class Outer {
inner class Inner {
fun getOuterRef(): Outer = this@Outer // Outer에 대한 this 참조. 참조를 저장하기 때문에 inner class
}
}
  • Java에서는 A 클래스 안에 B 클래스를 정의하면 자동으로 정적 내부 클래스가 되었음 (즉, Outer class에 대한 참조가 있음)
  • 만약 nested class로 바꾸고 싶다면, static으로 바꾸어 묵시적으로 참조하고 있던 Outer class 참조를 없앤다.
  • 하지만 코틀린에서는 반대임. 처음부터 묵시적으로 Outer를 들고 있지 않도록 설계함.
  • 한 클래스 내부에서 다른 클래스를 정의하면 기본적으로 중첩 클래스가 됨 (즉, Outer class에 대한 참조가 없음)
  • 내부 클래스로 만들고 싶다면 inner 키워드로 선언해야 함
  • 예를 들어 아래 코드가 있다고 해보자.
class Outer {
class Nested {
}
}
  • 위 코드를 디컴파일링하면 다음과 같다.
public final class Outer {
public static final class Nested {
}
}
// nested class 중첩 클래스
// 링크: https://shinjekim.github.io/kotlin/2019/08/29/Kotlin-%EB%82%B4%EB%B6%80-%ED%81%B4%EB%9E%98%EC%8A%A4(inner-class)%EC%99%80-%EC%A4%91%EC%B2%A9-%ED%81%B4%EB%9E%98%EC%8A%A4(nested-class)/
class Outer {
private val bar: Int = 1
class Nested {
fun foo() = 2
}
}
val demo = Outer.Nested().foo() // == 2
  • 중첩 클래스에서는 외부 클래스를 참조하지 않기 때문에 Outer.Nested().foo()의 값이 2가 된다.
// inner class 내부 클래스
class Outer {
private val bar: Int = 1
inner class Inner {
fun foo() = bar
}
}
val demo = Outer().Inner().foo() // == 1
  • 반면, 내부 클래스에서는 외부 클래스를 항상 참조하고 있기 때문에 Outer().Inner().foo()의 값이 1이 된다.
  • 객체를 항상 참조하고 있다는 것은 어떤 의미인가?
  • 자바에서 객체가 삭제되는 시점은 객체가 더 이상 사용되지 않을 때이다.
  • 그런데 내부 클래스를 사용하면 항상 외부 클래스의 객체를 참조하기 때문에 객체가 적절한 시점에 삭제되지 못한다.
  • 이러한 누수가 위험한 이유는 명시적(컴파일 시 오류가 발생하는 등 명식적으로 알 수 있는 것)인 것이 아니라 암묵적(프로그래머가 발견하기 전까지는 알 수 없는 것)인 것이기 때문이다.
  • 따라서 특별한 경우가 아니라면 내부 클래스 사용을 지양하고 중첩 클래스를 사용하는 것이 권장된다고 한다.

Kotlin은 왜 nested class를 기본으로 사용할까?

  • 참고 링크
  • 이펙티브 자바와 코틀린 인 액션 책에 따르면, 자바의 Inner class에는 다음과 같은 문제가 있다고 한다.
  • 직렬화에서 Inner classes를 사용할 경우 직렬화에 문제가 있다.
  • Inner class 내부에 숨겨진 Outer class 정보를 보관하게 되고, 결국 참조를 해지하지 못하는 경우가 생기면 메모리 누수가 생길 수도 있고, 코드를 분석하더라도 이를 찾기 쉽지 않아 해결하지 못하는 경우도 생긴다.
  • Inner classes를 허용하는 자바는 Outer를 참조하지 않아도 기본 inner classes이기 때문에 불필요한 메모리 낭비와 성능 이슈를 야기한다.
  • Kotlin은 처음부터 이를 배제하고 기본 Nested classes를 따르도록 설계했다고 보아도 좋을 것 같다.
  • 그래서, 상황에 따른 Inner classes 정의와 Nested classes 정의를 사용해야 한다. 그게 어렵다면 코틀린에서는 기본 Nested classes를 활용하는 게 맘 편하다.
  • 굳이 inner를 명시해 사용할 필요는 없다. 더더욱 Outer를 참조할 필요가 없다면 굳이 inner를 명시할 필요는 없고, 불필요한 메모리 낭비를 사전에 막을 수 있다.
  • 코틀린에서 사용할 경우 몇 가지 룰을 만든다면 다음과 같을 수 있다.
  • Outer의 멤버를 참조할 필요가 없다면 굳이 inner 키워드를 사용치 않아야 한다.
  • Inner classes를 보호하고 싶다면 private를 명시해라.
  • 수정 가능한 상태로 두고 싶다면 open 키워드를 명시할 수 있다.

Sealed class

  • Sealed class: 클래스 계층 정의 시 계층 확장 제한
  • 같은 파일 안에서만 하위 클래스 선언 가능
  • 다른 파일에 하위 클래스를 선언하면 컴파일 에러가 나지만, sealed 클래스를 상속한 하위 클래스를 다른 파일에서 다시 상속하는 것은 가능하다.
  • 실드 클래스 그 자체로는 추상 클래스와 같아 인스턴스 생성 불가능하다.
  • 생성자가 기본적으로 private, private이 아닌 생성자 허용하지 않는다.
  • 블록 안에 선언되는 클래스는 상속이 필요한 경우 open 키워드로 선언한다.
  • 실드 클래스는 상태를 정의하고 관리하는데 주로 사용한다.
  • sealed class를 when 식과 함께 사용하면 유용하다.
  • 하기 코드 출처
// 실드 클래스 선언 방법 첫번째

sealed class Result{
open class Success(val message: String): Result()
class Error(val code :Int , val message: String): Result()
}

class Status: Result() // 실드 클래스 상속은 같은 파일에서만 가능!
class Inside: Result.Success("Status") // 내부 클래스 상속
// 실드 클래스 선언 방법 두번째
sealed class Result

open class Success(val message: String) : Result()
class Error(val code: Int, val message: String): Result()

class Status: Result()
class Inside: Success("Status")

fun main(){
val result = Result.Success("Good!")
val msg = eval(result)
println(msg)
}

fun eval(result: Result): String = when(result){
is Status -> "in progress"
is Result.Success -> result.message
is Result.Error -> result.message
//모든 조건을 가지므로 else가 필요가 없다! 다른 값이 들어오는 등 불안정한 상태를 방지할 수 있음
}
  • when 식과 함께 사용하면 아무래도 유용하다.
  • when 식에 else 분기를 사용하지 않아도, 한 파일에 하위 타입이 몰려 있으므로 쉽게 분기 상에서 누락된 타입을 확인할 수 있다.
sealed class Expr { // mark a base class as sealed
class Num(val value: Int): Expr()
class Sum(val left: Expr, val right: Expr): Expr() // and list all the possible subclasses as nested classes.
}
fun eval(e: Expr): Int =
when(e) { // the "when" expression covers all possible cases, so no "else" branch is needed.
is Expr.Num -> e.value
is Expr.Sum -> eval(e.right) + eval(e.left)
}

주 생성자와 초기화 블록

// 클래스 이름 뒤의 constructor로 주 생성자 지정
class User constructor(_nickname: String) {
val nickname: String

init { // 초기화 블록
nickname = _nickname
}
}
// 프로퍼티를 생성자 파라미터로 초기화
// 별다른 annotation이나 가시성 수식어가 없다면 constructor는 생략할 수 있다.
class User(_nickname: String) {
val nickname = _nickname
}
class C(nameParam: String) { val name: String init {
if (nameParam.isEmpty()) {
throw IllegalArgumentException("Error")
}
this.name = nameParam
}
}
// 파라미터로 프로퍼티를 바로 초기화
class User(val nickname: String)
// 디폴트 값
class User(val nickname: String, val isSubscribed: Boolean = true)
  • 기반 클래스 생성자 호출
  • 기반 클래스 이름 뒤에 괄호를 치고 생성자 인자 전달
open class User(val nickname: String, val isSubscribed: Boolean = true)// User 클래스의 생성자 호출
class TwitterUser(nickname: String): User(nickname)
  • 디폴트 생성자
  • 자바에서는 클래스의 상속과 인터페이스의 구현을 extends 와 implements로 구분하지만, 코틀린에서는 이를 구분하지 않고 콜론(:) 뒤에 상속한 클래스나 구현한 인터페이스를 표기한다.
  • 별도 생성자를 정의하지 않으면, 컴파일러가 자동으로 아무 일도 하지 않는 인자 없는 디폴트 생성자를 만듦.
open class Button // 인자 없는 디폴트 생성자
class RadioButton: Button() // 기반 클래스의 인자 없는 생성자 호출
  • 수식어와 constructor
  • 생성자에 수식어를 붙이려면 constructor 키워드가 필요하다.
class Secretive private constructor() {}

Secondary Constructor

  • 여러 가지 방법으로 인스턴스를 초기화할 방법이 필요한 경우, secondary constructor를 사용한다.
  • 클래스 몸체에 constructor로 secondary constructor를 정의한다.
  • 주 생성자(Primary Constructor)에서는 constructor 키워드를 생략할 수 있었지만, 부 생성자는 constructor 키워드를 생략할 수 없다.
open class View {
constructor(ctx: Context) { ... }
constructor(ctx: Context, attr: AttributeSet) { ... }
}
class MyButton: View {
// 다른 부 생성자 호출
constructor(ctx: Context): this(ctx, MY_STYLE) { ... }
// 상위 클래스 생성자 호출
constructor(ctx: Context, attr: AttributeSet): super(ctx, attr) {
...
}
}
  • 자바와 마찬가지로 생성자에게 this()를 통해 클래스 자신의 다른 생성자를 호출할 수 있다.
class MyButton: view{
// 다른 생성자에게 위임
constuctor(ctx: Context): this(ctx, MY_STYLE){
// ...
}
constuctor(ctx: Context, attr: AttributeSet): super(ctx, attr){
// ...
}
}
  • 주 생성자가 존재한다면 부 생성자는 무조건 주 생성자에게 직간접적으로 생성을 위임해야 한다. 그래서 name, age를 파라미터로 가지는 생성자는 주 생성자에게 this(name)을 통해 생성을 위임해야 한다.
  • 이 때, name, age, height을 가지는 생성자는 this(name, age) 생성자에 위임하고, 이 생성자는 다시 this(name) 생성자에 위임하므로 간접적으로 주 생성자에게 생성을 위임한다고 볼 수 있다.
class D(val name: String) {    var age: Int = 20
var height: Int = 500
// 컴파일 에러!
// constructor(name: String, age: Int) {
// this.age = age
// }
constructor(name: String, age: Int) : this(name) {
this.age = age
}
constructor(name: String, age: Int, height: Int) : this(name, age) {
this.height = height
}
}
  • 부 생성자와 init 블록 중 어느 것이 먼저 호출될까?
  • 아래 결과를 보면 Init 블록이 부생성자보다 먼저 실행되는 것을 확인할 수 있다.
class E {
// https://velog.io/@conatuseus/Kotlin-%EC%83%9D%EC%84%B1%EC%9E%90-%EB%BF%8C%EC%8B%9C%EA%B8%B0
var name: String
var age: Int = 1
var height: Int = 2
init {
println("call Init Block!")
}
constructor(name: String) {
this.name = name
println("call Name Constructor!")
}
constructor(name: String, age: Int) : this(name) {
this.age = age
println("call Name, Age Constructor!")
}
constructor(name: String, age: Int, height: Int) : this(name, age) {
this.height = height
println("call Name, Age, Height Constructor!")
}
}
val e = E("conans", 100, 200)

인터페이스의 추상 프로퍼티와 그 구현

  • 우리는 인터페이스에 추상 프로퍼티를 구현할 수 있다.
  • 지원 필드나 getter는 없다.
interface User {
val nickname: String // abstract property
}
  • Backing Field란 이런 것!
var counter = 0 // 이 initializer는 backing field를 직접 할당함.
set(value) {
if(value >= 0) field = value
}
  • 인터페이스를 구현하는 하위 클래스에서 상태 저장을 위한 프로퍼티를 별도로 구현해야 함
class PrivateUser(override val nickname: String): User
class SubscribingUser(val email: String): User {
override val nickname: String
// custom getter
get() = email.substringBefore('@')
}
class FacebookUser(val accountId: Int): User {
override val nickname = getFBName(accountId) // 프로퍼티 초기화 식, 지원 필드에 초기화 식 결과 저장
}
  • 인터페이스에서 getter와 setter 있는 프로퍼티를 선언할 수도 있음
interface User {
val email: String // 추상 프로퍼티
val nickname: String
get() = email.substringBefore('@') // 지원 필드 없음
}
  • Tip) property에 getter와 setter를 쓰는 것은 옵션이며, 초기화 값이나 getter/setter의 리턴 타입으로 프로퍼티의 타입을 유추할 수 있으면 프로퍼티의 타입은 생략해도 된다.
var allByDefault: Int? // 에러: 명시적인 초기화를 해주어야만 함. 디폴트 getter/setter가 포함됨.
var initialized = 1 // Int 타입이며, 디폴트 getter/setter를 가짐
val simple: Int? // Int 타입이며 디폴트 getter를 가짐. 생성자에서 초기화해주어야만 한다.
val inferredType = 1 // Int 타입이며 디폴트 getter를 가짐.
  • setter가 기본적으로 property에 생기니 이거 막으려고 귀찮다.
var setterVisibility: String = "abc"
private set // setter는 private이고 디폴트 구현이 되어있음.
var setterWithAnnotation: Any? = null
@Inject set // Inject로 setter를 선언함.
  • 인터페이스에는 추상 프로퍼티뿐만 아니라 getter와 setter가 있는 프로퍼티를 선언할 수도 있다.
  • 이 떄의 getter와 setter는 backing field를 참조할 수 없다.
  • 왜냐하면 backing field가 있다면 인터페이스에 상태를 추가하는 셈인데, 인터페이스는 상태를 저장할 수 없기 때문이다.
  • 인터페이스에 선언된 프로퍼티와는 달리, 클래스에 구현된 프로퍼티는 backing field를 원하는 대로 사용할 수 있다.
  • getter와 setter에서 backing field에 접근할 수 있다.
  • 접근자 몸체에서 field라는 식별자로 지원 필드에 접근할 수 있다.
  • getter에서는 field 값을 읽을 수만 있지만, setter는 읽거나 쓸 수 있다. private 지정하려면 별도로 명시해야 한다. (귀찮아 보인다…)
  • field를 사용하지 않는 커스텀 접근자 구현 정의하면 지원 필드는 존재하지 않는다.
class User(val name: String) {
val address: String = "unspecified"
set(value: String) {
println("changed $field -> $value")
field = value
}
}
  • Tip) property에 auto-generate되는 getter와 setter가 싫다면 @JvmField 라는 어노테이션을 붙여주면 된다. 참고

By default, Kotlin will generate getter/setter for public property.

Use @JvmField to instruct the Kotlin compiler not to generate getters/setters for this property and expose it as a field.

class Test (
@JvmField
var sequenceNumber: Int = 0
)
  • 접근자 가시성 변경
  • 접근자 가시성은 기본적으로 프로퍼티 가시성과 같다.
  • get이나 set 앞에 가시성 수식어를 추가해 가시성을 변경할 수 있다.
class LengthCounter {
var counter: Int = 0
private set
fun addWord(word: String) {
counter += word.length // set이 private
}
}

Data class

data class Car (val manufacturer: String, var model: String)
  • 다음 메소드를 자동으로 생성한다.
  • equals() : 인스턴스 비교를 위한 메소드
  • hashCode() : 해시 기반 컨테이너에서 키로 사용할 수 있는 메소드
  • toString() : 각 필드를 선언 순서대로 표시하는 문자열 표현을 만들어주는 메소드
  • 이 때, 주 생성자에 나열된 모든 프로퍼티를 고려해 equals()와 hashCode()가 생성되고 주 생성자 밖에 있는 프로퍼티는 고려 대상이 아니다.
  • Data class 덕분에 destructuring pattern이 가능하다.
val car = Car("현대", "그랜저", "그랜저 IG")
val (manufacturer, _, model) = car
>>> println("제조사 : ${manufacturer}, 모델 : ${model}")
  • Data class가 아닌 일반 클래스였다면 다음과 같이 해야 한다.
/* 일반 클래스에서 구조 분해 선언하는 예제 */
class Car(val manufacturer: String, val model: String) {
operator fun component1() = manufacturer
operator fun component2() = model
}

데이터 클래스: copy()

val lee = Client("이재성", 41225)
println(lee.copy(postalCode = 4000)) // name은 그대로 복사
  • 객체 복사를 편하게 해주는 copy() 메소드가 존재한다.
  • 객체를 복사하면서 일부 프로퍼티를 변경하도록 해주는 기능이 있다.

Class Delegation of using by keyword

참고 참고 2

  • 하나의 클래스를 다른 클래스에 위임하도록 선언하여 위임된 클래스가 가지는 인터페이스 메소드를 참조 없이 호출할 수 있도록 생성해주는 기능이다.
interface A { ... }
class B: A { }
val b = B()// C를 생성하고, A에서 정의하는 B의 모든 메소드를 C에 위임한다.
class C: A by B
  • 만약 interface A 를 구현하고 있는 class B가 있다면, A 에서 정의하고 있는 B 의모든 메소드를 class C로 위임할 수 있다.
  • class Cclass B 가 구현한 interface A 의 모든 메소드를 가지며, 이를 클래스 위임(class delegation) 이라고 부른다.

클래스 위임의 내부

interface Base {
fun printX()
}
class BaseImpl(val x: Int) : Base {
override fun printX() { print(x) }
}
val baseImpl = BaseImpl(10)
class Derived(baseImpl: Base) : Base by baseImpl
  • 상기 코드를 Java로 디컴파일하면 다음과 같다.
public interface Base {
void printX();
}
public final class BaseImpl implements Base {
private final int x;
public void printX() {
int var1 = this.x;
System.out.print(var1);
}
// ...
}
public final class Derived implements Base {
// $FF: synthetic field
private final Base $$delegate_0;
public Derived(@NotNull Base baseImpl) {
Intrinsics.checkParameterIsNotNull(baseImpl, "baseImpl");
super();
this.$$delegate_0 = baseImpl;
}
public void printX() {
this.$$delegate_0.printX();
}
}
  • 위의 코드를 보면 $$delegate_0가 Base 타입의 본래 인스턴스를 참조할 수 있도록 생성된다.
  • 그리고 printX()도 정적 메소드로 생성되어 $$delegate_0printX()를 실행할 수 있도록 생성된다.
  • 따라서, 우리가 Derived를 사용할 때 Base에 대한 명시적 참조를 생략하고, printX() 메소드를 호출하는 것이 가능하다.

왜 등장했을까?

A common problem in the design of large object-oriented systems is fragility caused by implementation inheritance. When you extend a class and override some of its methods, your code becomes dependent on the implementation details of the class you’re extending. When the system evolves and the implementation of the base class changes or new methods are added to it, the assumptions about its behavior that you’ve made in your class can become invalid, so your code may end up not behaving correctly.

The design of Kotlin recognizes this problem and treats classes as final by default. This ensures that only those classes that are designed for extensibility can be inherited from. When working on such a class, you see that it’s open, and you can keep in mind that modifications need to be compatible with derived classes.

  • Decorator Pattern을 활용하면 문제를 해결할 수 있지만, 너무 많은 보일러플레이트 코드를 필요로 한다.
class DelegatingCollection<T> : Collection<T> {
private val innerList = arrayListOf<T>()
override val size: Int get() = innerList.size
override fun isEmpty(): Boolean = innerList.isEmpty()
override fun contains(element: T): Boolean = innerList.contains(element)
override fun iterator(): Iterator<T> = innerList.iterator()
override fun containsAll(elements: Collection<T>): Boolean =
innerList.containsAll(elements)
}
  • 위의 코드가 아래와 같이 바뀐다.
class DelegatingCollection<T>(
innerList: Collection<T> = ArrayList<T>()
) : Collection<T> by innerList {}
  • 예시
class CountingSet<T>(
val innerSet: MutableCollection<T> = HashSet<T>()
// Collection<T> 타입에 대한 메소드 호출 시 innerList에 위임
// Delegates the MutableCollection implementation to innerSet
) : MutableCollection<T> by innerSet {
var objectsAdded = 0
// Does not delegate; provides a different implementation
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)
}
}

왜 클래스 위임을 사용해야 할까

  • 클래스 위임이 인스턴스에 대한 참조없이 구현된 메소드를 사용하는 더 쉬운 방법이라고 이해해볼 수 있다.
  • 기본적으로 Kotlin의 클래스는 JVM의 final 속성을 가지고 있어 상속을 방지하고 있으므로, 만약 상속이 가능한 클래스를 만들고 싶은 경우 open 키워드를 사용해야 한다.
  • 우리는 Kotlin의 클래스 위임(Class Delegation)을 통해 상속의 방식 대신 Delegate Pattern을 응용할 수 있다. 클래스 위임은 다음과 같은 기능을 제공한다.
  • 별도의 추가 코드 없이 상속(Inheritance)의 대안 제공
  • 인터페이스에 의해 정의된 메소드만 호출할 수 있도록 보호
  • private 필드에 위임된 인스턴스를 저장하여 직접적인 접근 차단
  • 가장 중요한 점은 클래스 위임을 통해 모듈을 유연하게 구성할 수 있다는 것으로, 캡슐화와 다형성을 구현하는 방법을 보여준다.
interface Vehicle {
fun go(): String
}
class CarImpl(val where: String): Vehicle {
override fun go() = "is going to $where"
}
class AirplaneImpl(val where: String): Vehicle {
override fun go() = "is flying to $where"
}
class CarOrAirplane(
val model: String,
impl: Vehicle
): Vehicle by impl {
fun tellMeYourTrip() {
println("$model ${go()}")
}
}
fun main(args: Array<String>) {
val myAirbus330
= CarOrAirplane("Lamborghini", CarImpl("Seoul"))
val myBoeing337
= CarOrAirplane("Boeing 337", AirplaneImpl("Seoul"))

myAirbus330.tellMeYourTrip()
myBoeing337.tellMeYourTrip()
}
  • 클래스 위임으로 DI(Dependency Injection)을 구현하면 어떻게 될까? 코드
interface Heater {
fun on()
fun off()
fun isHot() : Boolean
}
class ElectricHeater(var heating: Boolean = false) : Heater {
override fun on() {
println("~ ~ ~ heating ~ ~ ~")
heating = true
}
override fun off() {
heating = false
}
override fun isHot() : Boolean {
return heating
}
}
interface Pump {
fun pump()
}
class Thermosiphon(heater: Heater) : Pump, Heater by heater {
override fun pump() {
if (isHot()) {
println("=> => pumping => =>");
}
}
}
interface CoffeeModule {
fun getThermosiphon() : Thermosiphon
}
class MyDripCoffeeModule : CoffeeModule {
companion object {
val electricHeater: ElectricHeater by lazy {
ElectricHeater()
}
}
private val _thermosiphon : Thermosiphon by lazy {
Thermosiphon(electricHeater)
}

override fun getThermosiphon() : Thermosiphon = _thermosiphon
}
class CoffeeMaker(val coffeeModule: CoffeeModule) {
fun brew() {
coffeeModule.getThermosiphon().run {
on()
pump()
println(" [_]P coffee! [_]P ")
off()
}
}
}
fun main(args: Array<String>) {
val coffeeMaker = CoffeeMaker(MyDripCoffeeModule())
coffeeMaker.brew();
}

객체 선언: 싱글톤

  • Singleton 선언을 지원하는데 object 키워드를 사용한다.
  • 클래스 정의, 클래스의 인스턴스 생성, 변수에 인스턴스 저장을 한 문장으로 처리한다.
object Payroll {
val allEmployees = arraylistOf<Person>()
fun calculateSalary() {
for (person in allEmployees) {
...
}
}
}
Payroll.allEmployees.add(Person(...))
Payroll.calculateSalary() // 객체 선언 이름 뒤에 마침표로 메서드나 프로퍼티 접근
  • comparator를 object로 구현할 수도 있다.
  • 클래스나 인터페이스를 상속할 수 있다.
object CaseInsensitiveFileComparator : Comparator<File> {
override fun compare(file1: File, file2: File): Int {
return file1.path.compareTo(file2.path,
ignoreCase = true)
}
}
  • Nested class에서 Comparator를 구현할 수도 있다.
  • 클래스 안에 객체를 선언할 수 있다.
data class Person(val name: String) {
object NameComparator : Comparator<Person> {
override fun compare(p1: Person, p2: Person): Int =
p1.name.compareTo(p2.name)
}
}
  • object는 특정 클래스나 인터페이스를 확장해서 만들 수 있다. (var obj = object:MyClass(){} 또는 var obj = object:MyInterface{}) 또는 위처럼 선언문이 아닌 표현식(var obj = object{})으로 생성할 수 있다.
  • 싱글톤이기 때문에 시스템 전체에서 쓸 기능(메소드로 정의)을 수행하는 데는 큰 도움이 될 수 있지만, 전역 상태를 유지하는 데 쓰면 스레드 경합 등으로 위험할 수 있으니 주의해서 사용해야 한다.
  • 언어 수준에서 안전한 싱글턴을 만들어 준다는 점에서 object는 매우 유용하다.

object를 활용하여 내부 익명 객체를 정의할 수 있다.

var clickCount = 0
window.addMouseListener(
object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
// 객체 식이 포함된 함수의 변수에 접근 가능
// 변수도 객체 식 안에서 사용할 수 있음
clickCount++
}
...
}
)

동반 객체 (Companion Object)

참고

Classes in Kotlin can’t have static members; Java’s static keyword isn’t part of the Kotlin language. As a replacement, Kotlin relies on package-level functions (which can replace Java’s static methods in many situations) and object declarations (which replace Java static methods in other cases, as well as static fields).

class MyClass{
companion object{
val TEST = "test"
fun method(i:Int) = i + 10
}
}
fun main(args: Array<String>){
println(MyClass.TEST); //test
println(MyClass.method(1)); //11
}
  • companion object는 클래스 내부에 정의되는 object의 특수한 형태이다.

Companion Object는 static이 아니다.

class MyClass2{
companion object{
val prop = "나는 Companion object의 속성이다."
fun method() = "나는 Companion object의 메소드다."
}
}
fun main(args: Array<String>) {
//사실은 MyClass2.맴버는 MyClass2.Companion.맴버의 축약표현이다.
println(MyClass2.Companion.prop)
println(MyClass2.Companion.method())
}
  • MyClass2 클래스에 companion object를 만들어 2개의 멤버를 정의했다.
  • 이를 사용하는 main() 함수를 보면 이 멤버에 접근하기 위해 클래스명.Companion형태로 쓴 것을 확인할 수 있다.
  • 이로써 유추할 수 있는 것은 companion object는 MyClass2 클래스가 메모리에 적재되면서 함께 생성되는 동반(companion)되는 객체이고 이 동반 객체는 클래스명.Companion으로 접근할 수 있다는 점이다.
fun main(args: Array<String>) {
//사실은 MyClass2.맴버는 MyClass2.Companion.맴버의 축약표현이다.
println(MyClass2.prop)
println(MyClass2.method())
}
  • 하지만, 위 코드에서 MyClass2.prop와 MyClass2.method()는 MyClass2.Companion.prop과 MyClass2.Companion.method() 대신 쓰는 syntatic sugar이다. (자바스크립트의 prototype과 유사한 듯 하다)

Companion Object는 객체이다.

class MyClass2{
companion object{
val prop = "나는 Companion object의 속성이다."
fun method() = "나는 Companion object의 메소드다."
}
}
fun main(args: Array<String>) {
println(MyClass2.Companion.prop)
println(MyClass2.Companion.method())
// companion object는 객체이므로 변수에 할당할 수 있다. 그리고 할당한 변수에서 점(.)으로 MyClass2 정의된 companion object의 맴버에 접근할 수 있다.
// 이렇게 변수에 할당하는 것은 자바의 클래스에서 static 키워드로 정의된 멤버로는 불가능한 방법이다.
val comp1 = MyClass2.Companion
println(comp1.prop)
println(comp1.method())
// .Companion을 빼고 직접 MyClass2로 할당한 것인데, 이 또한 MyClass2에 정의된 companion object이다.
// 위에서 MyClass2.Companion.prop 대신 MyClass2.prop로 해도 같다는 점을 생각해보면 쉽게 가능함을 유추할 수 있다.
// 아무튼 클래스 내 정의된 companion object는 클래스 이름만으로도 참조 접근이 가능하다.
val comp2 = MyClass2 //--(2)
println(comp2.prop)
println(comp2.method())
}
  • static 키워드 만으로는 클래스 멤버를 Companion Object처럼 별도의 객체로 취급할 수 없다. 이쯤되면 모든 것은 객체라고 하는 파이썬의 대전제가 떠오른다.

클래스 내에 Companion Object는 딱 하나만 쓸 수 있다.

class MyClass5{
companion object{
val prop1 = "나는 Companion object의 속성이다."
fun method1() = "나는 Companion object의 메소드다."
}
companion object{ // -- 에러발생!! Only one companion object is allowed per class
val prop2 = "나는 Companion object의 속성이다."
fun method2() = "나는 Companion object의 메소드다."
}
}
  • 위처럼 만들면 Only one companion object is allowed per class 에러가 발생할 것이다.

인터페이스 내에도 Companion Object를 정의할 수 있다.

interface MyInterface{
companion object{
val prop = "나는 인터페이스 내의 Companion object의 속성이다."
fun method() = "나는 인터페이스 내의 Companion object의 메소드다."
}
}
fun main(args: Array<String>) {
println(MyInterface.prop)
println(MyInterface.method())
val comp1 = MyInterface.Companion
println(comp1.prop)
println(comp1.method())
val comp2 = MyInterface
println(comp2.prop)
println(comp2.method())
}
  • 덕분에 인터페이스 수준에서 상수항을 정의할 수 있고, 관련된 중요 로직을 이곳에 기술할 수 있다.

Companion Object도 인터페이스 구현이나 클래스 확장이 가능하다.

interface JSONFactory<T> { ... }
class Person(vavl name: String) {
companion object : JSONFactory<Person> { ... }
}
fun loadFromJSON<T>(factory: JSONFactory<T>): T { ... }
loadFromJSON(Person) // 동반객체 전달
  • Companion Object도 객체니까 당연한 이치이다.

Companion Object도 확장 함수가 가능하다.

class Person(val firstName: String, val lastName: String) {
companion object {}
}
fun Person.Companion.fromJSON(json: String): Person { ... }
val p = Person.fromJSON(json) // 동반 객체의 확장 함수 실행

생각할 거리

  • 객체 생성을 위해 new 키워드를 쓰지만 코틀린에서는 new 키워드 없이 클래스 명 뒤에 괄호()를 붙인다는 점을 상기해보면, 코틀린에서 new를 쓰지 않는다는 점이 과연 무슨 의미일까?
  • 왜 요즘 언어는 (안티 패턴이라고 하는) auto-generated getter, setter를 기본으로 지원할까?
  • 확장 함수(Extension Function)은 어떻게 작동할까? 참고

--

--

Responses (2)