[kotlin] 클래스, 객체, 인터페이스

sonnie
lucky-sonnie
Published in
21 min readJan 3, 2021

4.1 클래스 계층 정의

4.1.1 코틀린 인터페이스

interface Clickable{
fun click()
fun showOff() = println("I'm clickable")
}

class Button: Clickable {
override fun click() {
println("I was clicked")
}
}

자바에서는 extends와 implements 키워드를 사용하지만, 코틀린에서는 클래스 이름 뒤에 콜론(:)을 붙이고 인터페이스와 클래스 이름을 적는 것으로 클래스 확장과 인터페이스 구현을 모두 처리한다.

자바와 달리 코틀린에서는 override 변경자를 꼭 사용해야 한다. 상위 클래스에 있는 메소드와 시그니처가 같은 메소드를 우연히 우연히 하위 클래스에서 선언하는 경우 컴파일이 안 되기 때문에 override를 붙이거나 메소드 이름을 바꿔야만 한다.

interface Clickable{
fun click()
fun showOff() = println("I'm clickable")
}

이 인터페이스를 구현하는 클래스는 click에 대한 구현을 제공해야 한다. 반면 showOff 메소드의 경우 새로운 동작을 정의할 수도 있고, 그냥 정의를 생략해서 디폴트 구현을 사용할 수도 있다.

interface Focusable {
fun setFocus(b: Boolean)
fun showOff() = println("I'm focusable!")
}

한 클래스에서 Focusable과 Clickable이 함께 구현된다면 어느 쪽 showOff 메소드가 선택될까? 어느 쪽도 선택되지 않는다. 컴파일러 오류가 발생한다.

class Button: Clickable, Focusable {
override fun click() {
println("I was clicked")
}
override fun showOff() {
super<Clickable>.showOff()
super<Focusable>.showOff()
}
}

Button 클래스는 이제 두 인터페이스를 구현한다. Button은 상속한 두 상위 타입의 showOff() 메소드를 호출하는 방식으로 showOff() 를 구현한다.

둘중에 하나만 상속받고 싶다면 아래처럼 하면 된다.

override fun showOff() = super<Clickable>.showOff()

4.1.2 open, final, abstract 변경자: 기본적으로 final

코틀린의 클래스와 메소드는 기본적으로 final이다. 어떤 클래스의 상속을 허용하려면 클래스 앞에 open 변경자를 붙여야 한다. 그와 더불어 오버라이드를 허용하고 싶은 메소드나 프로퍼티의 앞에도 open 변경자를 붙여야 한다.

open class RichButton: Clickable{    // final. 하위 클래스에서 메소드 오버라이드 불가능
fun disable(){}
// 하위 클래스에서 이 메소드를 오버라이드 해도 된다.
open fun
animate(){}
// 상위 클래스에서 오버라이드 받은 메소드는 기본적으로 열려있다.
override fun
click() {
TODO
("Not yet implemented")
}
}

코틀린에서도 클래스를 abstract로 선언할 수 있다. abstract로 선언한 추상 클래스는 인스턴스화할 수 없다. 추상 멤버는 항상 열려있기 때문에 추상 멤버 앞에 open 변경자를 명시할 필요가 없다.

abstract class Animated{    // 하위 클래스에서 반드시 오버라이드 해야함
abstract fun animate()
// 추상 클래스에 속했더라도 비추상 함수는 기본적으로 final이지만 open으로 오버라이드를 허용할 수 있다.
open fun
stopAnimating(){}
fun
animateTwice(){}
}

4.1.3 가시성 변경자: 기본적으로 공개

자바의 default 는 public 이다. 자바의 기본 가시성인 패키지 전용은 코틀린에 없다. 코틀린은 패키지를 네임스페이스를 관리하기 위한 용도로만 사용한다. 그래서 패키지를 가시성 제어에 사용하지 않는다.

패키지 전용 가시성에 대한 대안으로 코틀린에는 internal 이라는 새로운 가시성 변경자를 도입했다. internal은 “모듈 내부에서만 볼 수 있음” 이라는 뜻이다. 모듈은 한번에 한꺼번에 컴파일되는 코틀린 파일들을 의미한다.

모듈 내부 가시성은 모듈 구현에 대해 진정한 캡술화를 제공한다는 장점이 있다. 또한 코틀린에서는 최상위 선언에 대해 private 가시성을 허용한다는 점이다. 그런 최상위 선언에는 클래스, 함수, 프로퍼티 등이 포함된다. 비공개 가시성인 최성위 선언은 그 선언이 들어있는 파일 내부에서만 사용할 수 있다.

internal open class TalkativeButton: Focusable{
private fun yell() = println("Hey!!")
protected fun whisper() = println("Let's talk.")
}
// public 멤버가 internal 수신 타입을 노출함
fun TalkativeButton.giveSpeech(){
yell() // 오류남. private으로 선언되어 있어서 접근 불가.
whisper() // 오류남. protected로 선언되어 있어서 접근불가.
}

어떤 클래스의 기반 타입 목록에 들어있는 타입이나 제네릭 클래스의 타입 파라미터에 들어있는 타입의 가시성은 그 클래스 자신의 가시성과 같거나 더 높아야 하고, 메소드의 시그니처에 사용된 모든 타입의 가시성은 그 메소드의 가시성과 같거나 더 높아야 한다. 이러한 규칙은 어떤 함수를 호출하거나 어떤 클래스를 확장할 때 필요한 모든 타입에 접근할 수 있게 보장해준다.

자바와 코틀린의 protected가 다르다. protected 멤버는 오직 어떤 클래스나 그 클래스를 상속한 클래스 안에서만 보인다. 클래스를 확장한 함수는 그 클래스의 private이나 protected 멤버에 접근할 수 없다.

4.1.4 내부 클래스와 중첩된 클래스: 기본적으로 중첩 클래스

class Outer{
inner class Inner{
fun getOuterReference(): Outer = this@Outer
}
}

내부 클래스 Inner 안에서 바깥쪽 클래스 Outer의 참조에 접근하려면 this@Outer 라고 써야한다.

4.1.5 봉인된 클래스: 클래스 계층 정의 시 계층 확장 제한

코틀린 컴파일러는 when 을 사용해 Expr 타입의 값을 검사할 때 꼭 디폴트 분기인 else분기를 덧붙이게 강제한다. 항상 디폴트 분기를 추가하는게 편하지는 ㅇ낳다. 그리고 디폴트 분기가 있으면 이런 클래스 계층에 새로운 하위 클래스를 추가하더라도 컴파일러가 when이 모든 경우를 처리하는지 제대로 검사할 수 없다.

상위 클래스에 sealed 변경자를 붙이면 그 상위 클래스를 상속한 하위 클래스 정의를 제한할 수 있따. sealed 클래스의 하위 클래스를 정의할 때는 반드시 상위 클래스 안에 중첩시켜야 한다.

sealed class Expr2{
class
Num(val value: Int) : Expr2()
class Sum(val left: Expr2, val right: Expr2) : Expr2()

}

fun eval(e: Expr2):Int=
when(e) {
is Expr2.Num -> e.value
is Expr2.Sum -> eval(e.right) + eval(e.left)
}

sealed 로 표시된 클래스는 자동으로 open이다.

내부적으로 Expr 클래스는 private 생성자를 가진다. 그 생성자는 클래스 내부에서만 호출할 수 있다. sealed 인터페이스를 정의할 수는 없다. 봉인된 인터페이스를 만들 수 있다면 그 인터페이스를 자바 쪽에서 구현하지 못하게 막을 수 있는 수단이 코틀린 컴파일러에게 없기 때문이다.

4.2 뻔하지 않은 생성자와 프로퍼티를 갖는 클래스 선언

4.2.1 클래스 초기화: 주 생성자와 초기화 블록

class User(val nickname: String)

위처럼 클래스 이름 뒤에 오는 괄호로 둘러싸인 코드를 주 생성자라고 부른다. 주 생성자는 생성자 파라미터를 지정하고 그 생성자 파라미터에 의해 초기화되는 프로퍼티를 정의하는 두 가지 목적에 쓰인다.

class User(_nickname: String){ 
val nickname = _nickname
}

주 생성자의 파라미터로 프로퍼티를 초기화한다면 그 주 생성자 파라미터 이름 앞에 val을 추가하는 방식으로 프로퍼티 정의와 초기화를 간략히 쓸 수 있다.

class User(val nickname: String)

아래 두 개의 User 선언은 모두 같다. 하지만 마지막 선언이 가장 간결하다. 함수 파라미터와 마찬가지로 생성자 파라미터에도 디폴드 값을 정의할 수 있다.

class User(val nickname: String, val isSubscribed: Boolean = true)

모든 파라미터에 디폴트 값을 지정하면 컴파일러가 자동으로 파라미터가 없는 생성자를 만들어준다. 그렇게 자동으로 만들어진 파라미터 없는 생성자는 디폴트 값을 사용해 클래스를 초기화한다.

open class Button // 인자가 없는 디폴트 생성자가 만들어진다.
class RadioButton: Button()

Button 의 생성자는 아무 인자도 받지 않지만, Button 클래스를 상ㅅ혹한 하위 클래스는 반드시 Button 클래스의 생성자를 호출해야 한다.

어떤 클래스를 클래스 외부에서 인스턴스화하지 못하게 막고 싶다면 모든 생성자를 private으로 만들면 된다.

class Secretive private constructor(){}

4.2.2 부 생성자: 상위 클래스를 다른 방식으로 초기화

부 생성자는 constructor 키워드로 시작한다. 필요에 따라 부 생성자를 많이 ㅎ선언해도 된다.

class MyButton: View{
constructor(ctx: Context): super(ctx){}
constructor(ctx: Context, attr: Attribute): super(ctx, attr){}
}

여기서 두 생성자는 super() 키워드를 통해 자신에 대응하는 상위 클래스 생성자를 호출한다. 상위 클래스 생성자에게 객체 생성을 위임하는 것이다.

자바와 마찬가지로 생성자에서 this() 를 통해 클래스 자신의 다른 생성자를 호출할 수 있다.

class MyButton: View{
constructor(ctx: Context): this(ctx, MY_STYLE){}
constructor(ctx: Context, attr: Attribute): super(ctx, attr){}
}

위 코트블럭의 첫번째 constructor는 두번째 constructor에게 생성을 위임하고 2번째 constructor는 상위 클래스에게 생성을 위임한다. 클래스에 주 생성자가 없다면 모든 부 생성자는 반드시 상위 클래스를 초기화하거나 다른 생성자에게 생성을 위임해야 한다.

부 생성자가 필요한 주된 이유는 자바 상호운용성이다. 하지만 부 생성자가 필요한 다른 경우도 있다. 클래스 인스턴스를 생성할 때 파라미터 목록이 다른 생성 방법이 여럿 존재하는 경우에는 부 생성자를 여럿 둘 수밖에 없다.

4.2.3 인터페이스에 선언된 프로퍼티 구현

코틀린에서는 인터페이스에 추상 프로퍼티 선언을 넣을 수 있따.

interface User{
val nickname: String
}

이는 User 인터페이스를 구현하는 클래스가 nickname의 값을 얻을 수 있는 방법을 제공해야 한다는 뜻이다. 인터페이스에 있는 프로퍼티 선언에는 뒷받침하는 필드나 게터등의 정보가 들어있지 않다. 사실 인터페이스는 아무 상태도 포함할 수 없으므로 상태를 저장할 필요가 있다면 인터페이스를 구현한 하위 클래스에서 상태 저장을 위한 프로퍼티 등을 만들어야 한다.

class PrivateUser(override val nickname: String):User

PrivateUser 는 주 생성자 안에 프로퍼티를 직접 선언하는 간결한 구문을 사용한다. 이 프로퍼티는 User의 추상 프로퍼티를 구현하고 있으므로 override를 표시해야 한다.

class SubscribingUser(val email: String): User{
override val nickname: String
get() = email.substringBefore("@")
}

SubscribingUser는 커스텀 getter로 nickname 프로퍼티를 설정한다. 이 프로퍼티는 뒷받침하는 필드에 값을 저장하지 않고 매번 이메일 주소에서 별명을 계산해 반환한다.

class FacebookUser(val accountId: Int): User{
override val nickname = getFacebookName(accountId)
}

FacebookUser 에서는 초기화 식으로 nickname 값을 초기화한다. 이때 페이스북 사용자 ID를 받아서 그 사용자의 이름을 반환해주는 getFacebookName 함수를 호출해 nickname을 초기화한다.

인터페이스에는 추상 프로퍼티뿐 아니라 게터와 세터가 있는 프로퍼티를 선언할 수도 있다. 물론 그런 게터와 세터는 뒷받침하는 필드를 참조할 수 없다.

interface User{
val email: String
val nickname: String
get() = email.substringBefore("@")
}

위의 예제 코드에서 email 에 뒷받침 하는 필드가 없고, 그 대신 매번 결과를 계산해 돌려준다. 이 인터페이스에는 추상 프로퍼티인 email과 커스텀 게터가 있는 nickname 프로퍼티가 함께 들어있다. 하위 클래스는 추상 프로퍼티인 email을 반드시 오버라이드 해야한다. 반면 nickname은 오버라이드하지 않고 상속할 수 있다.

4.2.4 게터와 세터에서 뒷받침하는 필드에 접근

위의 두가지 유형(매번 값 계산하는 프로퍼티와 값을 저장하는 프로퍼티)을 조합해 어떤 값을 저장하되 그 값을 변경하거나 읽을 때마다 정해진 로직을 실행하는 유형의 프로퍼티를 만드는 방법을 살펴본다. 값을 저장하는 동시에 로직을 실행할 수 있게 하기 위해서는 접근자 안에서 프러퍼티를 뒷받침하는 필드에 접근할 수 있어야 한다.

class User(val name: String){
var address: String="unspecified"
set(value: String){
println("""
Address was changed for $name:
"$field" -> "$value".""".trimIndent())
field = value // 뒷밭침하는 필드 값 변경하기
}
}

프로퍼티의 값을 바꿀 때는 user.address= “new value” 처럼 필드 설정 구문을 사용한다.

클래스의 프로퍼티를 사용하는 쪽에서 프로퍼티를 읽는 방법이나 쓰는 방법은 뒷받침하는 필드의 유무와는 관계가 없다. 컴파일러는 디폴트 접근자 구현을 사용하건 직접 게터나 세터를 정의하건 관계없이 게터나 세터에서 field를 사용하는 프로퍼티에 대해 뒷받침하는 필드를 생성해준다.

4.2.5 접근자의 가시성 변경

접근자의 가시성은 기본적으로 프로퍼티의 가시성과 같다.

class LengthCounter{
var counter: Int = 0
private set
fun
addWord(word: String){
counter += word.length
}
}

외부 코드에서 단어 길이의 합을 마음대로 바꾸지 못하게 이 클래스 내부에서만 길이를 변경하게 만들고 싶다. 그래서 기본 가시성을 가진 게터를 컴파일러가 생성하게 내버려 두는 대신 세터의 가시성을 private 으로 지정한다.

4.3 컴파일러가 생성한 메소드: 데이터 클래스와 클래스 위임

4.3.1 모든 클래스가 정의해야 하는 메소드

  • toString()

기본 객체 문자열 표현(Client@5e9f23b4, 참조값) 에서 원하는 문자열로 출력할 수 있다.

  • equals()
val client1 = Client("소현", 3123)
val client2 = Client("소현", 3123)

위의 두 객체는 다른데 만약 서로 다른 두 객체가 내부에 동일한 데이터를 포함하는 경우 그 둘을 동등한 객체로 간주해야 할 때 equals() 메소드를 오버라이드 한다.

== : equals, === : 참조값

코틀린의 is 검사는 자바의 instanceOf 와 같다. 코틀린에서는 overrode 변경자가 필수여서 실수로 override fun equals(other: Any?) 대신 override fun equals(other: Client) 를 작성할 수는 없다. 그래서 equals를 오버라이드 하고 나면 프로퍼티의 값이 모두 같은 두 client 객체는 동등하리라 예상할 수 있다. 하지만 이 Client클래스로 더 복잡한 작업을 수행하다보면 제대로 작동하지 않는 경우가 있다. hashCode 정의를 빠뜨려서 그렇다.

  • 해시 컨테이너: hashCode()

자바에서는 equals를 오버라이드 할 때 반드시 hashCode도 함께 오버라이드해야 한다.

val processed = hashSetOf(Client("소현", 3123))
println(processed.contains(Client("소현", 3123)))
>>> false

원소가 ‘소현’이라는 고객 하나뿐인 집합을 만들자. 그후 새로 원래의 ‘소현’과 똑같은 프로퍼티를 포함하는 새로운 Client 를 만들어서 그 인스턴스가 집합 안에 들어있는지 검사해보자. 프로퍼티가 모두 일치하므로 새 인스턴스아 집합에 있는 기존 인스턴스는 동등하다. 그래서 true를 리턴할 것이라고 생각하지만 false를 리턴한다.

이는 Client 클래스가 hashCode 메소드를 정의하지 않았기 때문이다. JVM 언어에서는 hashCode가 지켜야 하는 “equals()가 true를 반환하는 두 객체는 반드시 같은 hashCode()를 반환해야 한다.”라는 제약이 있는데 Client는 equals 만 정의해주고 hashCode는 정의해주지 않았다.

processed 집합은 HashSet이다. HashSet은 원소를 비교할 때 비용을 줄이기 위해 먼저 객체의 해시 코드를 비교하고 해시 코드가 가은 경우에만 실제 값을 비교한다. 위의 예제에서는 두 Client 인스턴스의 해시코드가 다르기 때문에 두 번째 인스턴스가 집합 안에 들어있지 않다고 판단한다.

Client 객체에 hashCode를 구현하면 이제 위 예제의 결과값은 true가 된다.

4.3.2 데이터 클래스: 모든 클래스가 정의해야 하는 메소드 자동 생성

코틀린에서 data라는 변경자를 클래스 앞에 붙이면 필요한 메소드를 컴파일러가 자동으로 만들어준다. data 변경자가 붙은 클래스를 데이터 클래스라고 부른다.

데이터 클래스가 포함하는 메소드

  • 인스턴스 간 비교를 위한 equals
  • HashMap과 같은 해시 기반 컨테이너에서 키로 사용할 수 있는 hashCode
  • 클래스의 각 필드를 선언 순서대로 표시하는 문자열 표현을 만들어주는 toString

데이터 클래스와 불변성: copy() 메소드

데이터 클래스의 프로퍼티가 꼭 val일 필요는 없다. 하지만 데이터 클래스의 모든 프로퍼티를 읽기 전용으로 만들어서 데이터 클래스를 불변 클래스로 만들라고 권장한다.

데이터 클래스 인스턴스를 불변 객체로 더 쉽게 활용할 수 있게 코틀린 컴파일러는 한가지 편의 메소드를 제공한다. 그 메소드는 객체를 복사하면서 일부 프로퍼티를 바꿀 수 있게 해주는 copy 메소드다. 객체를 메모리상에서 직접 바꾸는 대신 복사본을 만드는 편이 더 낫다. 복사본은 원본과 다른 생명주기를 가지며 복사를 하면서 일부 프로퍼티 값을 바꾸거나 복사본을 제거해도 프로그램에서 원본을 참조하는 다른 부분에 전혀 영향을 끼치지 않는다.

class Client(val name: String, val postalCode: Int){
...
fun copy(name: String = this.name,
postalCode: Int = this.postalCode)
= Client(name, postalCode)
}
>>> val lee = Client("소현", 3123)
>>> println(lee.copy(postalCode = 4000))
Client(name="소현", postalCode=4000)

4.3.3 클래스 위임: by 키워드 사용

상속을 허용하지 않는 클래스에 새로운 동작을 추가해야 할 때가 있따. ㅣ럴 때 사용하는 일반적인 방법이 데코레이터 패턴이다.

코틀린은 인터페이스를 구현할 때 by 키워드를 통해 그 인터페이스에 대한 구현을 다른 객체에 위임 중이라는 사실을 명시할 수 있다.

class DelegatingCollectopm<T>(
innerList: Collection<T> = ArrayList<T>()
) : Collection<T> by innerList{ }

메소드 중 일부의 동작을 변경하고 싶은 경우 메소드를 오버라이드하면 컴파일러가 생성한 메소드 대신 오버라이드한 메소드가 쓰인다. 기존 클래스의 메소드에 위임하는 기본 구현으로 충분한 메소드는 따로 오버라이드할 필요가 없다.

4.4 Object 키워드 : 클래스 선언과 인스턴스 생성

object 키워드를 사용하는 여러 상황

  • 객체 선언은 싱글턴을 정의하는 방법 중 하나다.
  • 동반 객체는 인스턴스 메소드는 아니지만 어떤 클래스와 관련 있는 메소드와 팩토리 메소드를 담을 때 쓰인다. 동반 객체 메소드에 접근할 때는 동반 객체가 포함된 클래스의 이름을 사용할 수 있다.
  • 객체 식은 자바의 무명 내부 클래스 대신 쓰인다.

4.4.1 객체 선언: 싱글턴을 쉽게 만들기

코틀린은 객체 선언 기능을 통해 싱글턴을 언어에서 기본 지원한다. 객체 선언은 클래스 선언과 그 클래스에 속한 단일 인스턴스의 선언을 합친 선언이다.

object Payroll{
val allEmployee = arrayListOf<Person>()
fun calculateSalary(){
for(person in allEmployees){}
}
}

객체 선언은 object 키워드로 시작한다. 객체 선언은 클래스를 정의하고 그 클래스의 인스턴스를 만들어서 변수에 저장하는 모든 작업을 단 한 문장으로 처리한다.

예를들어 Comparator 안에는 데이터를 저장할 필요가 없기 때문에 보통 클래스마다 단 하나씩만 있으면 된다. 따라서 Comparator 인스턴스를 마드는 방법으로는 객체 선언이 가장 좋은 방법이다.

코틀린 객체 선언은 유일한 인스턴스에 대한 정적인 필드가 있는 자바 클래스로 컴파일된다. 이때 인스턴스 필드의 이름은 항상 INSTANCE 다.

CaseInsensitiveFileComparator.INSTANCE.comapare(file1, file2)

4.4.2 동반 객체: 팩토리 메소드와 정적 멤버가 들어갈 장소

코틀린 언어는 자바 static 키워드를 지원하지 않는다. 그 대신 코틀린에서는 패키지 수준의 최상위 함수와 객체 선언을 활용한다.

class A{
companion object{
fun bar(){
println("companion object called")
}
}
}
>>> A.bar()

클래스 안에 정의된 객체 중 하나에 companioon 이라고 붙이면 그 클래스의 동반 객체로 만들 수 있다. 이때 객체의 이름을 따로 지정할 필요가 없다. 그 결과 동반 객체의 멤버를 사용하는 구문은 자바의 정적 메소드 호출이나 정적 필드 사용 구문과 같아진다.

동반 객체는 자신을 둘러싼 클래스의 모든 private 멤버에 접근할 수 있다. 따라서 동반 객체는 바깥쪽 클래스의 private 생성자도 호출할 수 있다.

4.4.3 동반 객체를 일반 객체처럼 사용

동반 객체는 클래스 안에 정의된 일반 객체다. 따라서 동반 객체에 이름을 붙이거나, 동반 객체가 인터페이스를 상속하거나, 동반 객체 안에 확장 함수와 프로퍼티를 정의할 수 있다.

class Person(val name: String){
companion object Loader{
fun fromJSON(jsonText: String): Person = ...
}
}

동반 객체에 대한 확장 함수를 정의하는 방법은 아래의 코드 참고

class Person(val firstName: String, val lastName: String){
companion object{} // 비어있는 동반 객체를 선언한다.
}
// 확장 함수를 선언한다.
fun Person.Companion.fromJSON(json: String): Person{ }

4.4.4 객체 식: 무명 내부 클래스를 다른 방식으로 작성

무명 객체를 정의할 때도 object 키워드를 쓴다. 무명 객체는 자바의 무명 내부 클래스를 대신한다.

window.addMouseListener(
object: MouseAdapter(){
override fun mouseClicked(e: MouseEvent){ }
override fun mouseEntered(e: MouseEvent){ }
}
)

한 인터페이스만 구현하거나 한 클래스만 확장할 수 있는 자바의 무명 내부 클래스와 달리 코틀린에서는 여러 인터페이스를 구현하거나 클래스를 확장하면서 인터페이스를 구현할 수 있다.

출처: 코틀린 인 액션

--

--