반응형
Notice
Recent Posts
Recent Comments
Link
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
Tags
more
Archives
Today
Total
관리 메뉴

안드로이드 개발자 노트

[코틀린 완벽 가이드] 4장 : 클래스와 객체 다루기 본문

Kotlin/코틀린 완벽 가이드

[코틀린 완벽 가이드] 4장 : 클래스와 객체 다루기

어리둥절범고래 2022. 9. 29. 21:45
반응형

1. 클래스 정의하기


1. 클래스 내부 구조

 

클래스는 class 키워드 다음에 클래스 이름이 오고 그다음에 클래스 본문이 오는 형태로 정의된다. 클래스 안에는 프로퍼티와 함수가 정의될 수 있다.

 

class Person {
    // 프로퍼티 부분
    var firstName: String = ""
    var familyName: String = ""
    var age: Int = 0
    
    // 함수 부분
    fun fullName() = "$firstName $familyName"
    fun showMe() {
        println("${fullName()}: $age")
    }
}

 

이 정의는 인스턴스에 firstName, familyName, age라는 프로퍼티와 fullName, showMe라는 함수가 들어있음을 알려준다.

일반적으로 프로퍼티는 어떤 클래스의 구체적인 인스턴스와 엮여있기 때문에 인스턴스를 식으로 지정한다. 이런 인스턴스를 수신 객체라 부르고, 수신 객체는 프로퍼티에 접근할 때 사용해야 하는 객체를 지정한다.

 

클래스 내부에서는 this 식으로 수신 객체를 참조할 수 있다.

수신 객체의 멤버 안에서 수신 객체의 멤버를 참조할 때는 this를 생략해도 된다. 하지만 this가 꼭 필요한 경우도 있다.

어떤 클래스의 프로퍼티와 메서드 파라미터 이름이 같은 경우, 이 둘을 구분하기 위해 프로퍼티 이름 앞에 this를 사용한다.

 

class Person {
    var firstName: String = ""
    var familyName: String = ""
    
    fun setName(firstName: String, familyName: String) {
        this.firstName = firstName
        this.familyName = familyName
    }
}

 

프로퍼티가 사용하는 내부 필드는 항상 캡슐화돼 있고 클래스 정의 밖에서는 이 내부 필드에 접근할 수 없다.

클래스 인스턴스의 프로퍼티나 메서드를 사용하려면 인스턴스를 명시적으로 생성해야 한다. 이를 생성자 호출이라고 하며 일반 함수 호출과 생성자 호출의 차이점은 일반 함수 호출은 함수 이름으로 하고, 생성자 호출은 클래스 이름을 사용한다.

 

fun main() {
    val person = Person() // Person 인스턴스 생성
    
    person.firstName = "John"
    person.familyName = "Doe"
    person.age = 25
    
    person.showMe() // John Doe : 25
}

 

생성자 호출을 사용하면 프로그램이 새 인스턴스에 대한 힙 메모리를 할당한 다음, 인스턴스의 상태를 초기화해주는 생성자 코드를 호출해 준다. 함수와 마찬가지로 기본적으로 코틀린 클래스는 공개(pubilc) 가시성이다. internal이나 private으로 설정할 수 있다.

 

클래스 프로퍼티는 지역 변수와 마찬가지로 불변일 수 있지만, 프로퍼티의 값을 지정할 수단이 있어야 한다. 그렇지 않으면 모든 클래스가 같은 프로퍼티를 사용하게 된다.

 


2. 생성자

 

생성자는 클래스 인스턴스를 초기화해주고 인스턴스 생성 시 호출되는 함수이다.

주생성자인 init과 부생성자인 constructor가 있다.

 

class Person(firstName: String, familyName: String) {
    val fullName = "$firstName $familyName"
}

fun main() {
    val person = Person("John", "Doe") // 새 Person 인스턴스 생성
    println(person.fullName)           // John Doe
}

 

클래스 헤더의 파라미터 목록을 주생성자(primary constructor) 선언이라고 부른다.

주생성자는 클래스 정의 내에서 프로퍼티 초기화와 init이라는 키워드가 붙은 초기화 블록이 등장한다.

초기화 블록에는 return 문이 들어가지 못한다.

 

class Person(firstName: String, familyName: String) {
    val fullName = "$firstName $familyName"
    
    init {
        // error: 'return' is not allowed here
        if (firstName.isEmpty() && familyName.isEmpty()) return
        println("Created new Person instance: $fullName")
    }
}

 

컴파일러는 모든 프로퍼티가 확실히 초기화되는지 확인한다. 컴파일러가 주생성자의 모든 실행 경로가 모든 멤버 프로퍼티를 초기화하거나 (일부 프로퍼티가 초기화되지 않는 경우) 예외를 발생시키는지 확인할 수 없다면 오류가 발생한다.

주생성자 파라미터를 프로퍼티 초기화나 init 블록 밖에서 사용할 수는 없다. 이에 대한 해법은 생성자 파라미터의 값을 저장할 멤버 프로퍼티를 정의하는 것이다. 코틀린은 간단하게 생성자 파라미터 값을 멤버 프로퍼티로 만들 수 있는 방법을 제공한다.

 

class Person(val firstName: String, familyName: String) {
    // firstName은 생성자 파라미터를 가리킴
    val fullName = "$firstName $familyName"
    
    fun printFirstName() {
        println(firstName) // firstName은 멤버 프로퍼티를 가리킴
    }
}

fun main() {
    val person = Person("John", "Doe")
    println(person.firstName) // firstName은 멤버 프로퍼티를 가리킴
}

 

기본적으로 생성자 파라미터 앞에 val이나 var 키워드를 덧붙이면, 자동으로 해당 생성자 파라미터로 초기화되는 프로퍼티를 정의한다.

이때 파라미터 이름을 프로퍼티 초기화나 init 블록 안에서 참조하면 생성자 파라미터를 가리키고, 다른 위치에서 참조하면 프로퍼티를 가리키게 된다.

val/var 파라미터와 디폴트를 사용하면 본문을 아예 생략할 수 있다. 실제 인텔리J 코틀린 플러그인은 이런 스타일을 권장한다.

 

class Person(val firstName: String, val familyName: String = "") {
}

 

함수와 마찬가지로 vararg를 생성자 파라미터에 사용할 수 있다.

 

class Room(vararg val persons: Person) {
    fun showNames() {
        for (person in persons) println(person.fullName())
    }
}

fun main() {
    val room = Room(Person("John"), Person("Jane", "Smith"))
    room.showNames()
}

 

여러 생성자를 사용해 클래스 인스턴스를 서로 다른 방법으로 초기화 하고 싶을 때 부생성자(secondary constructor)를 사용할 수 있다.

(이런 경우도 대부분 디폴트 파라미터를 사용하는 주생성자로 해결할 수 있지만, 경우에 따라 주생성자만으로는 충분하지 않을 수 있다.)

부생성자 문법은 함수 이름 대신에 constructor 키워드를 사용한다는 점을 제외하면 함수 정의 문법과 비슷하다.

 

class Person {
    val firstName: String
    val familyName: String
    
    constructor(firstName: String, familyName: String) {
        this.firstName = firstName
        this.familyName = familyName
    }
    
    constructor(fullName: String) {
        val names = fullName.split(" ")
        if (names.size != 2) {
            throw IllegalArgumentException("Invalid name: $fullName")
        }
        firstName = name[0]
        familyName = name[1]
    }
}

 

부생성자에 반환 타입을 지정할 수는 없지만, 기본적으로 Unit 타입 값을 반환하는 함수와 같은 형태다.

init 블록과 달리 부생성자 안에서 return을 사용할 수 있다.

부생성자가 생성자 위임 호출을 사용해 다른 부생성자를 호출하는 것이 있다.

생성자 파라미터 목록 뒤에 콜론을 넣고 그 뒤에 일반 함수를 호출하는 것처럼 코드를 작성하되, 함수 이름 대신 this를 사용하면 생성자 위임 호출이 된다.

 

class Person {
    val fullName: String
    constructor(firstName: String, familyName: String):
        this("$firstName $familyName")
    constructor(fullName: String) {
        this.fullName = fullName
    }
}

 

클래스에 주생성자가 있다면, 모든 부생성자는 주생성자에게 위임을 하거나 다른 부생성자에게 위임을 해야한다.

부생성자의 파라미터 목록에는 val/var 키워드를 쓸 수 없다.

 

class Person(val fullName: String) {
    constructor(firstName: String, familyName: String):
        this("$firstName $familyName")
}

 


3. 멤버 가시성

 

코틀린에서는 클래스 멤버의 가시성을 다음과 같은 변경자 키워드로 지정할 수 있다.

가시성을 사용해 구현과 관련한 세부 사항을 캡슐화함으로써 외부 코드로부터 구현 세부사항을 격리시킬 수 있다.

 

  • public(공개): 멤버를 어디서나 볼 수 있다. 디폴트 가시성이 바로 public이다. 따라서 명시적으로 public을 표시할 필요가 없다.
  • internal(모듈 내부): 멤버를 멤버가 속한 클래스가 포함된 컴파일 모듈 내부에서만 볼 수 있다.
  • protected(보호): 멤버를 멤버가 속한 클래스와 멤버가 속한 클래스의 모든 하위 클래스 안에서 볼 수 있다.
  • private(비공개): 멤버를 멤버가 속한 클래스 내부에서만 볼 수 있다.

 

함수와 프로퍼티, 주생성자, 부생성자에 대해 가시성 변경자를 지원한다. 함수와 프로퍼티, 주생성자/부생성자는 모두 클래스 본문에 정의되거나 주생성자 파라미터로 정의된다. 주생성자의 가시성을 지정하려면 constructor 키워드를 꼭 명시해야 한다.

 

class Empty private constructor() {
    fun showMe() = println("Empty")
}

fun main() {
    // error: cannot access '<init>': it is private in 'Empty'
    Empty().showMe()
}

 

Empty 클래스의 유일한 생성자가 private이므로 이 클래스를 클래스 본문 외부에서 인스턴스화 할 수 없다.

 


4. 내포된 클래스

 

코틀린 클래스는 프로퍼티, 함수, 생성자 외에도 다른 클래스도 멤버로 가질 수 있다. 이런 클래스를 내포된 클래스(nested class)라고 부른다.

 

class Person (val id: Id, val age: Int) {
    class Id(val firstName: String, val familName: String)
    fun showMe() = println("${Id.firstName} ${Id.familyName}, $age")
}

fun main() {
    val id = Person.Id("John", "Doe")
    val person = Person(id, 25)
    person.showMe()
}

 

내포된 클래스는 이름 앞에 바깥쪽 클래스의 이름을 덧붙여야지만 내포된 클래스를 참조할 수 있다.

다른 멤버와 마찬가지로 내포된 클래스에도 여러 가지 가시성을 지정할 수 있다. 내포된 클래스도 자신을 둘러싼 클래스의 멤버이므로 자신을 포함하는 클래스의 비공개 선언에 접근할 수 있다.

 

class Person (private val id: Id, private val age: Int) {
    class Id(private val firstName: String, private val familyName: String) {
        fun nameSake(person: Person) = person.id.firstName == firstName
    }
    
    // error: cannot access 'familyName': it is private in 'Id'
    fun showMe() = println("${id.firstName} ${id.familyName}, $age")
}

 

자바와 달리, 바깥쪽 클래스는 자신에게 내포된 클래스의 비공개 멤버에 접근할 수 없다.

내포된 클래스에 inner를 붙이면 자신을 둘러싼 외부 클래스의 현재 인스턴스에 접근할 수 있다.

여기서 내부 클래스 생성자를 호출할 때 외부 클래스 인스턴스를 지정해야 한다.

 

class Person (val firstName: String, val familyName: String) {
    inner class Possession(val description: String) {
        fun showOwner() = println(fullName())
    }
    private fun fullName() = "$firstName $familyName"
}

fun main() {
    val person = Person("John", "Doe")
    // Possession 생성자 호출
    val wallet = person.Possession("Wallet")
    wallet.showOwner() // John Doe
}

 

다른 멤버들과 마찬가지로 내부 클래스를 가리킬 때도 this를 생략할 수 있다.

일반적으로 this는 항상 가장 내부의 클래스 인스턴스를 가리킨다. 따라서 내부 클래스 본문에서 this는 내부 클래스 자신을 가리킨다.

내부 클래스 본문에서 외부 클래스 인스턴스를 가리켜야 한다면 한정시킨(qualified) this 식을 사용해야 한다.

 

class Person(val firstName: String, val familyName: String) {
    inner class Possession(val description: String) {
        fun getOwner() = this@Person
    }
}

5. 지역 클래스

 

함수 본문에서도 클래스를 정의할 수 있다. 이런 지역 클래스는 자신을 둘러싼 코드 블록 안에서만 쓸 수 있다.

지역 함수와 비슷하게 지역 클래스도 자신을 둘러싼 코드의 선언에 접근할 수 있다. 지역 클래스는 클래스 본문 안에서 자신이 접근할 수 있는 값을 포획(capture)할 수 있고, 심지어 변경도 가능하다.

 

fun main() {
    var x = 1
    
    class Counter {
        fun increment() {
            x++
        }
    }
    
    Counter().increment()
    
    println(x) // 2
}

 

코틀린과 달리 자바에서는 포획한 변수의 값을 변경할 수 없으며 클래스 내부에서 포획한 변수를 사용할 경우에는 명시적으로 final로 선언해야 한다. 하지만 코틀린이 제공하는 포획 변수를 변경하는 기능은 그에 따른 비용을 수반한다. 익명 객체와 이 객체를 둘러싸고 있는 코드 사이에 변수를 공유하기 위해 코틀린 컴파일러는 공유되는 값을 특별한 래퍼(wrapper) 객체로 감싼다.(불변 변수의 경우 값이 바뀌지 않기 때문에 래퍼가 필요 없으므로 이런 부가 비용이 없다.)
위 Counter 예제를 컴파일한 바이트코드에 해당하는 자바 코드는 다음과 같다.

 

import kotlin.jvm.internal.Ref.IntRef;

class MainKt {
    public static void main(String[] args) {
        final IntRef x = new IntRef(); // 래퍼 생성
        x.element = 1;
        
        final class Counter {
            public final void increment() {
                x.element++; // 공유된 데이터 변경하기
            }
        }
        
        (new Counter()).increment();
        
        System.out.println(x.element); // 공유된 데이터 읽기
    }
}

 

내포된 클래스와 달리 지역 클래스에는 가시성 변경자를 붙일 수 없다.

지역 클래스의 영역은 항상 자신을 둘러싼 블록으로 제한된다.

지역 클래스도 함수, 프로퍼티, 생성자, 내포된 클래스 등 다른 클래스가 포함할 수 있는 모든 멤버를 포함할 수 있다.

하지만 내포된 클래스는 반드시 inner 클래스여야만 한다.

 

fun main(args: Array<String>) {
    class Foo {
        val length = args.size
        inner class Bar {
            val firstArg = args.firstOrNull()
        }
    }
}

2. 널 가능성

 

자바와 마찬가지로 코틀린의 참조 값에는 아무것도 참조하지 않는 경우를 나타내는 특별한 null이라는 값이 있다.

이 참조는 그 어떤 할단된 객체도 가리키지 않는 참조를 뜻한다.

 


1. 널이 될 수 있는 타입

 

자바에서 모든 참조 타입은 널이 될 수 있는 타입으로 간주된다. 하지만 코틀린에서는 기본적으로 모든 참조 타입은 널이 될 수 없는 타입이다. 따라서 String 같은 타입에 null 값을 대입할 수 없다. 주어진 문자열에 문자만 들어있는지 검사하는 다음 함수를 살펴보자.

 

fun isLetterString(s: String): Boolean {
    if (s.isEmpty()) return false
    for (ch in s) {
        if (!ch.isLetter()) return false
    }
    return true
}

fun main() {
    println(isLetterString("abc")) // ok
    // error: null can not be a value of a non-null type String
    println(isLetterString(null))
}

 

null을 s파라미터에 넘기면 컴파일 오류가 발생한다. String은 널을 받을 수 있는 타입이 아니기 때문이다.

코틀린에서 널이 될 수도 있는 값을 받는 함수를 작성하려면 파라미터 타입 뒤에 물음표(?)를 붙여서 타입을 널이 될 수 있는 타입으로 지정해야 한다.

 

fun isBooleanString(s: String?) = s == "false" || s == "true"

 

 

모든 널이 될 수 있는 타입은 원래 타입(?가 붙지 않은 타입)의 상위 타입이며 널이 될 수 있는 타입의 변수에 항상 널이 될 수 없는 타입의 값을 대입할 수 있다. 반대로 널이 될 수 없는 타입의 값에 널이 될 수 있는 타입의 변수를 대입할 수는 없다.

 

fun main() {
    println(isBooleanString(null)) // ok
    val s: String? = "abc"         // ok
    // error: type mismatch: inferred type is String? but String was expected
    val ss: String = s
}

 

런타임에 널이 될 수 없는 값은 실제로 널이 될 수 있는 값과 차이가 없다. 코틀린 컴파일러는 널이 될 수 없는 값을 표현하기 위해 어떤 래퍼도 사용하지 않는다. 따라서 런타임에는 어떠한 부가 비용도 들지 않는다.

 

가장 작은 널이 될 수 있는 타입은 Nothing? 이다. 이 타입은 널 상수 이외의 어떤 값도 포함하지 않는다. 이 타입은 null 값 자체의 타입이며 다른 모든 널이 될 수 있는 타입의 하위 타입이다.

 

가장 큰 널이 될 수 있는 타입은 Any? 이다. Any?는 코틀린 타입 시스템 전체에서 가장 큰 타입으로, 널이 될 수 있는 모든 타입과 널이 될 수 없는 모든 타입의 상위 타입이다.

 

널이 될 수 있는 타입은 원래 타입에 들어있는 어떤 프로퍼티나 메소드도 제공하지 않는다. isLetterString() 함수의 파라미터 타입을 String?으로 바꾸되, 나머지 부분을 그대로 두면 함수 본문의 모든 s 사용법이 잘못됐다고 컴파일 오류가 발생한다. String? 타입에 iterator() 메서드가 없기 때문에 for 루프를 사용해 널이 될 수 있는 문자열에 대한 이터레이션을 수행할 수는 없다.

 

fun isLetterString(s: String?): Boolean {
    // error: only safe (?.) or non-null asserted (!!.) calls are allowed
    on a nullable recevier of type String?
    if (s.isEmpty()) return false
    
    // error: not nullable value required to call an 'iterator()' method on for-loop range
    for (ch in s) {
        if (!ch.isLetter()) return false
    }
    return true
}

 


2. 널 가능성과 스마트 캐스트

 

널이 될 수 있는 값을 처리하는 가장 직접적인 방법은 해당 값을 조건문을 사용해 null과 비교하여 분기하는 것이다.

 

컴파일러는 코드 흐름의 가지 중 한쪽에서는 대상 값이 확실히 널이고 다른 가지에서는 확실히 널이 아니라는 사실을 알 수 있다. 컴파일러는 이런 정보를 사용해 값 타입을 세분화 함으로써 널이 될 수 있는 값을 널이 될 수 없는 값으로 타입 변환 한다. 이런 기능을 스마트 캐스트라고 부른다. 스마트 캐스트는 널 가능성에만 제한되지 않으며 클래스 계층 구조 안에서 스마트 캐스트를 통해 안전한 타입 캐스팅을 수행할 수도 있다. 스마트 캐스트는 if분기 뿐만 아니라 when이나 루프 같은 조건 검사가 들어가는 다른 문이나 식 안에서도 작동한다.

 

fun isLetterString(s: String?): Boolean {
    if (s == null) return false
    
    // s는 여기서 널이 될 수 없다.
    if (s.isEmpty()) return false
    
    for (ch in s) {
        if (!ch.isLetter()) return false
    }
    return true
}

fun describeNumber(n: Int?) = when (n) {
    null -> "null"
    // 아래에 있는 가지에서 n은 null이 될 수 없다.
    in 0..10 -> "small"
    in 11..100 -> "large"
    else -> "out of range"
}

// ||나 && 연산의 오른쪽에서도 같은 일이 벌어진다.
fun isSingleChar(s: String?) = s != null && s.length == 1

 

널 검사와 사용 지점 사이에서 값이 변경되는 경우에는 스마트 캐스트가 작동하지 않는다. 대상 변수의 값이 검사 지점과 사용 지점 사이에 변하지 않는다고 컴파일러가 확신할 수 없기 때문이다.

 

var s = readLine() // String?
if (s != null) {
    s = readLine()
    // 변수 값이 바뀌므로 스마트 캐스트를 쓸 수 없음
    // error: only safe (?.) or non-null asserted (!!.) calls are
    // allowed on a nullable receiver of type String?
    println(s.length)
}

 


3. 널 아님 단언 연산자

 

!! 연산자는 널 아님 단언(non-null assertion)으로, KotlinNullPointException 예외(NullPointException의 하위 클래스)를 발생시킬 수 있는 연산자다. 이 연산자가 붙은 식의 타입은 원래 타입의 널이 될 수 없는 버전이다. 일반적으로 널이 될 수 있는 값을 사용하려면 그냥 예외를 던지는 방식보다 더 타당한 응답을 제공해야 하기 때문에 이 연산자를 사용하지 말아야 한다. 하지만 이 연산자 사용을 정당화할 수 있는 경우가 있다.

 

fun main() {
    var name: String? = null
    
    fun initialize() {
        name = "John"
    }
    
    fun sayHello() {
        println(name!!.uppercase())
    }
    
    initialize()
    sayHello()
}

 

이 경우 sayHello() 안에서 변수 타입을 String으로 세분화 하지 못하기 때문에 널 아님 단언을 사용하는 것도 적절한 해법이 될 수 있지만, 컴파일러가 스마트 캐스트를 사용할 수 있게 하는 편이 더 낫다.

 


4. 안전한 호출 연산자

 

안전한 호출 연산자(safe null)는 수신 객체(왼쪽 피연산자)가 널이 아는 경우 일반적인 함수 호출처럼 작동한다. 하지만 수신 객체가 널이면 안전한 호출 연산자는 호출을 수행하지 않고 널을 돌려준다. ||나 &&와 비슷하게 지연 연산의 의미를 따르며 수신 객체가 널이면 안전한 호출 연산자는 함수의 인자를 계산하지 않는다. 안전한 호출을 사용하면 불필요한 if식과 임시 변수의 사용을 줄여서 코드를 단순화 할 수 있다.

 

println(readLine()?.toInt()?.toString(16))

 


5. 앨비스 연산자

 

널이 될 수 있는 값을 다룰 때 유용한 연산자로 널 복합 연산자(null coalescing operator)인 ?:을 들 수 있다. 이 연산자를 사용하면 널을 대신할 디폴트 값을 지정할 수 있다. 엘비스 프레슬리를 닮았기 때문에 널 복합 연산자를 보통 엘비스 연산자라고 부른다.

 

fun sayHello(name: String?) {
    println("Hello, " + (name ?: "Unknown"))
}

fun main() {
    sayHello("John")  // Hello, John
    sayHello(null)    // Hello, Unknown
}

 

안전한 연산과 엘비스 연산자를 조합해서 수신 객체가 널일 때의 디폴트 값을 지정하면 유용하다.

 

val n = readLine()?.toInt() ?: 0 // 입력이 널을 반환할 경우 0을 n에 대입한다.

 

더 간편한 패턴으로, return이나 throw 같은 제어 흐름을 깨는 코드를 엘비스 연산자 오른쪽에 넣는 방법도 있다.

 

class Name(val firstName: String, val familyName: String?)

class Person(val name: Name?) {
    fun describe(): String {
        val currentName = name ?: return "Unknown"
        return "${currentName.firstName} ${currentName.familyName}"
    }
}

fun main() {
    println(Person(Name("John", "Doe")).describe()) // John Doe
    println(Person(null).describe())  // Unknown
}

3. 단순한 변수 이상인 프로퍼티

 

코틀린 프로퍼티는 일반 변수를 넘어서, 프로퍼티 값을 읽거나 쓰는 법을 제어할 수 있는 훨씬 더 다양한 기능을 제공한다.

 


1. 최상위 프로퍼티

 

클래스나 함수와 마찬가지로 최상위 수준에 프로퍼티를 정의할 수도 있다. 이런 경우 프로퍼티는 전역 변수나 상수와 비슷한 역할을 한다.

이런 프로퍼티에 최상위 가시성을 지정할 수 있다. 그리고 임포트 디렉티브에서 최상위 프로퍼티를 임포트할 수도 있다.

 

//util.kt
package util

val prefix = "Hello, "

//main.kt
package main

import util.prefix

fun main() {
    val name = readLine() ?: return
    println("$prefix$name")
}

 


2. 늦은 초기화

 

클래스를 인스턴스화할 때 프로퍼티를 초기화해야 한다는 요구 사항이 불필요하게 엄격할 때가 있다. 예를 들어 널이 될 수 있는 값이 항상 사용 전에 초기화 되므로 절대 널이 될 수 없는 값이라는 사실을 알고 있음에도 불구하고 이 값을 사용할 때마다 널 가능성을 처리해야할 경우다.

프로퍼티를 lateinit으로 만들기 위해서는 몇 가지 조건을 만족해야 한다. 

 

  1. 프로퍼티가 코드에서 변경될 수 있는 지점이 여러 곳일 수 있으므로 프로퍼티를 가변 프로퍼티(var)로 정의해야 한다.
  2. 프로퍼티의 타입은 널이 아닌 타입이어야 하고 Int와 Boolean 같은 원시 값을 표현하는 타입이 아니어야 한다. 내부에서 lateinit 프로퍼티는 초기화되지 않은 상태를 표현하기 위해 null을 사용하는 널이 될 수 있는 값으로 표현되기 때문이다.
  3. lateinit 프로퍼티를 정의하면서 초기화 식을 지정해 값을 바로 대입할 수 없다. 이런 대입을 허용하면 애초 lateinit을 지정하는 의미가 없기 때문이다.

 

lateinit var text: String

fun readText() {
    text = readLine()!!
}

fun main() {
    readText()
    println(text)
}

 

코틀린 1.2부터 몇 가지 개선 사항을 도입했다. 최상위 프로퍼티와 지역 변수에서 늦은 초기화를 사용할 수 있게 됐으며, lateinit 프로퍼티 값을 읽기 전에 lateinit 프로퍼티가 설정됐는지를 알아보는 기능이 있다.

 


3. 커스텀 접근자 이용하기

 

코틀린의 실제 능력은 변수와 함수의 동작을 한 선언 안에 조합할 수 있는 커스텀 접근자(custon accessor)에 있다.

커스텀 접근자는 프로퍼티 값을 읽거나 쓸 때 호출되는 특별한 함수다.

 

class Person(val firstName: String, val familyName: String) {
    val fullName: String
    get(): String {
        return "$firstName $familyName"
    }
}

 

다음은 프로퍼티 값을 읽을 때 사용하는 커스텀 게터를 정의한다.

게터는 프로퍼티 정의 끝에 붙으며 기본적으로 이름 대신 get이라는 키워드가 붙은 함수처럼 보인다. 이런 프로퍼티를 읽으면 자동으로 게터를 호출한다.

 

fun main() {
    val person = Person("John", "Doe")
    println(person.fullName) // John Doe
}

 

식이 본문인 형태를 사용할 수 있다.

 

val fullName: String get() = "$firstName $familyName"

 

게터에는 파라미터가 없으며 게터의 반환 타입은 프로퍼티의 타입과 같다.

 

firstName, familyName과 달리 fullName에는 뒷받침하는 필드(backing field)가 없기 때문에 클래스 인스턴스에서 전혀 메모리를 차지하지 않는다. 즉, 기본적으로 fullName은 프로퍼티 형태의 함수와 같다.

 

프로퍼티에 명시적으로 field를 사용하는 디폴트 접근자나 커스텀 접근자가 하나라도 있으면 뒷받침하는 필드가 생성된다. 불변 프로퍼티의 접근자는 읽기 접근자 하나뿐이므로 앞 예제에서 fullName은 직접 뒷받침하는 필드인 field를 참조하지 않는다는 사실을 알 수 있다. 따라서 fullName 프로퍼티에는 뒷받침하는 필드가 없다. 뒷받침하는 필드 참조는 field라는 키워드를 사용하며 접근자의 본문 안에서만 사용할 수 있다. 프로퍼티가 어떤 저장된 값을 사용하지만 프로퍼티에 대한 접근을 커스텀해야할 경우, 뒷받침하는 필드를 사용하면 된다. 예를 들어 프로퍼티를 읽을 때마다 로그를 남기고 싶다면 다음과 같이 할 수 있다.

 

class Person(val firstName: String, val familyName: String, age: Int) {
    val age: Int = age
        get() {
        println("Accessing age")
        return field
    }
}

 

var로 정의하는 가변 프로퍼티에는 값을 읽기 위한 게터(getter)와 값을 설정하기 위한 세터(setter)라는 두가지 접근자가 있다.

 

프로퍼티 세터의 파라미터는 단 하나이며, 타입은 프로퍼티 자체의 타입과 같아야 한다. 프로퍼티를 초기화하면 값을 바로 뒷받침하는 필드에 쓰기 때문에 프로퍼티 초기화는 세터를 호출하지 않는다.

가변 프로퍼티에는 두 가지 접근자가 있으므로 두 접근자를 모두 커스텀화할 수 있고 두 접근자가 모두 다 field 키워드를 통해 뒷받침하는 필드를 사용하지 않는 경우를 제외하면 항상 뒷받침하는 필드가 생긴다.

 

class Person(val firstName: String, val familyName: String) {
    var age: Int? = null
    set(value) {
        if (value != null && value <= 0) {
            throw IllegalArgumentException("Invalid age: $value")
        }
        field = value
    }
}

fun main() {
    val person = Person("John", "Doe")
    person.age = 20 // 커스텀 세터를 호출
    println(person.age) // 20 (커스텀 게터를 호출)
}

 

프로퍼티 접근자에 별도로 가시성 변경자를 붙일 수도 있다. 클래스 외부에서는 프로퍼티의 값을 변경하지 못하게 해서 바깥에서는 객체가 불변인 것처럼 보이고 싶을 때 이런 방식을 사용할 수도 있다.

 

import java.util.Date

class Person(name: String) {
    var lastChanged: Date? = null
    private set // Person 클래스 밖에서는 변경할 수 없다.
    
    var name: String = name
    set (value) {
        lastChanged = Date() // Person의 name 프로퍼티에 값을 할당할 때 lastChanged값도 같이 할당한다.
        field = value
    }
}

 


4. 지연 계산 프로퍼티와 위임

 

어떤 프로퍼티를 처음 읽을 때까지 그 값에 대한 계산을 미뤄두고 싶을 때 사용 할 수 있는 lazy 프로퍼티가 있다.

text 프로퍼티는 lazy 블록으로 프로퍼티를 정의한다. 이 프로퍼티를 읽기 전까지, 프로그램은 lazy 프로퍼티의 값을 계산(초기화)하지 않는다. 초기화가 된 이후 프로퍼티의 값은 필드에 저장되고, 그 이후로는 프로퍼티 값을 읽을 때마다 저장된 값을 읽게 된다.

 

import java.io.File

val text: String by lazy {
    File("data.txt").readText()
}

fun main() {
    while(true) {
        when (val command = readLine() ?: return) {
            "print data" -> println(text)
            "exit" -> return
        }
    }
}

 

이 구문은 사실 위임 객체(delegate object)를 통해 프로퍼티를 구현하게 해주는 위임 프로퍼티(delegate property)라는 기능이다.

위임 객체는 by라는 키워드 다음에 위치하며, 객체를 반환할 수 있는 임의의 식이 될 수 있다.

lateinit 프로퍼티와 달리 lazy 프로퍼티는 가변 프로퍼티가 아닌 불변 프로퍼티이며, 스마트 캐스트를 사용할 수 없다.

 


 

4. 객체

 


1. 객체 선언

 

코틀린에서는 object 키워드를 사용하여 오직 하나의 인스턴스만을 생성하는 싱글턴 패턴을 내장하고 있다.

코틀린 싱글턴패턴은 클래스와 비슷한 방법으로 선언한다.

 

object Application {
    val name = "My Application"
    
    override fun toString() = name
    
    fun exit () { }
}

 

객체 정의는 스레드 안전하다. 컴파일러는 실행되는 여러 스레드에서 싱글턴에 접근하더라도 오직 한 인스턴스만 공유되고 초기화 코드도 단 한번만 실행되도록 보장한다. 초기화는 싱글턴 클래스가 실제 로딩되는 시점까지 지연된다. 보통은 프로그램이 객체의 인스턴스에 처음 접근할 때 초기화가 이뤄진다.

 

클래스 선언과 마찬가지로 객체 선언도 멤버 함수와 프로퍼티를 포함할 수 있고, 초기화 블록도 포함할 수 있다. 하지만 객체에는 주생성자나 부생성자가 없다. 객체 인스턴스는 항상 암시적으로 만들어지기 때문에 객체의 경우 생성자 호출이 아무런 의미가 없다.

 

객체의 본문에 들어가는 클래스는 inner가 붙을 수 없다. 내부 클래스의 인스턴스는 항상 바깥쪽 클래스의 인스턴스와 연관되는데, 객체 선언은 항상 인스턴스가 하나뿐이므로 inner 변경자가 불필요해지기 때문이다.

 


2. 동반 객체

 

내포된 클래스와 마찬가지로 내포 객체도 인스턴스가 생기면 자신을 둘러싼 클래스의 비공개 멤버에 접근할 수 있다.

이러한 특성으로 팩토리 메서드 패턴을 구현할 수 있다. 생성자를 비공개로 지정해 클래스 외부에서 사용할 수 없게 한 다음, 내포된 객체에 팩토리 메서드 역할을 하는 함수를 정의하고 그 함수 안에서 필요에 따라 객체의 생성자를 호출하는 것이다.

 

class Application private constructor(val name: String) {
    object Factory {
        fun create(args: Array<String>): Application? {
            val name = args.firstOrNull() ?: return null
            return Application(name)
        }
    }
}

fun main(args: Array<String>) {
    // 직접 생성자를 호출하도록 허용하지 않음
    // val app = Application(name)
    val app = Application.Factory.create(args) ?: return
    println("Application started: ${app.name}")
}

 

동반 객체를 사용하면 앞 예제를 더 간결하게 할 수 있다.

 

동반 객체는 companion 키워드를 덧붙인 내포된 객체이다. 동반 객체는 다른 내포된 객체와 마찬가지로 작동하지만 동반 객체의 멤버에 접근할 때만 동반 객체의 이름을 사용하지 않고 동반 객체가 들어있는 외부 클래스의 이름을 사용할 수 있다.

 

class Application private constructor(val name: String) {
    companion object Factory {
        fun create(args: Array<String>): Application? {
            val name = args.firstOrNull() ?: return null
            return Application(name)
        }
    }
}

fun main(args: Array<String>) {
    val app = Application.create(args) ?: return
    println("Application started: ${app.name}")
}

 

동반 객체는 정의에서 이름을 아예 생략할 수도 있다. 이런 방식을 더 권장한다.

 

class Application private constructor(val name: String) {
    companion object {
        fun create(args: Array<String>): Application? {
            val name = args.firstOrNull() ?: return null
            return Application(name)
        }
    }
}

 

동반 객체의 이름이 생략된 경우 디폴트 이름을 Companion으로 가정한다. 동반 객체를 임포트 시킬 때는 객체 이름을 명시해야만 한다.

클래스에 동반 객체가 둘 이상 있을 수도 없다.

 

class Application {
    companion object Factory
    // error: only one companion object is allowed per class
    companion object Utils
}

 


3. 객체 식

 

코틀린은 명시적인 선언 없이 객체를 바로 생성할 수 있는 특별한 식을 제공한다.

 

fun main() {
    fun midPoint(xRange: IntRange, yRange: IntRange) = object {
        val x = (xRange.first + xRange.last)/2
        val y = (yRange.first + yRange.last)/2
    }
    
    val midPoint = midPoint(1..5, 2..6)
    
    println("${midPoint.x}, ${midPoint.y}") // (3, 4)
}

 

midPoint() 함수가 반환하는 타입에 대해 명시적으로 타입을 지정하지 않았는데, 객체 식 안에 정의된 모든 멤버가 있는 들어있는 클래스를 표현하는 익명 객체 타입(anonymous object type)이며, 이런 타입은 단 하나만 존재한다. (멤버가 모두 완전히 똑같은 두 객체 식이 있다고 해도, 둘의 타입은 서로 다르다)

 

fun main() {
    val o = object { // 익명 객체 타입이 추론됨
        val x = readLine()!!.toInt()
        val y = readLine()!!.toInt()
    }
    println(o.x + o.y) // 여기서 o안의 x와 y에 접근할 수 있음
}

 

o는 객체 식의 타입이 익명 객체 타입이며, 지역 변수나 프로퍼티의 타입도 마찬가지일 수 있다. 하지만 익명 객체 타입은 지역 선언이나 비공개 선언에만 전달될 수 있다. 예를 들어 midPoint 함수를 최상위 함수로 정의하면 객체 멤버에 접근할 때 컴파일 오류가 발생한다.

 

fun midPoint(xRange: IntRange, yRange: IntRange) = object {
    val x = (xRange.first + xRange.last)/2
    val y = (yRange.first + yRange.last)/2
}

fun main() {
    val midPoint = midPoint(1..5, 2..6)
    // error: unresolved reference: x
    // error: unresolved reference: y
    println("${midPoint.x}, ${midPoint.y}")
}

 

midPoint() 함수의 타입은 객체 식에 해당하는 익명 객체 타입이 아니라 객체 식에 지정된 상위 타입이 된다.

하지만 예제 객체 식에는 상위 타입을 명시하지 않았기 때문에 Any를 상위 타입으로 가정한다. 그래서 midPoint.x 참조에서 x를 찾을 수 없는 것이다.

 

지역 함수나 클래스와 마찬가지로 객체 식도 자신을 둘러싼 코드 영역의 변수를 포획할 수 있다. 이렇게 포획한 가변 변수를 객체 본문에서 변경할 수 있으며, 지역 클래스와 비슷하게 데이터를 공유하기 위해 필요한 레퍼를 생성한다.

 

fun main() {
    var x = 1
    
    val o = object {
        fun change() {
            x = 2
        }
    }
    
    o.change()
    println(x) // 2
}

 

객체 식이 만들어내는 객체는 객체 인스턴스가 생성된 직후 바로 초기화된다. 다음 코드를 보면, o 정의에 객체가 생성되는 시점에 이 객체의 a 프로퍼티가 초기화되므로 o.a에 접근하기 전에 x를 표시했음에도 불구하고 x의 값으로 2가 표시된다.

 

fun main() {
    var x = 1

    val o = object {
        val a = x++
    }

    println("o.a = ${o.a}") // o.a = 1
    println("x = $x")       // x = 2
}

정리

 

주생성자 : 클래스 헤더에 파라미터를 받거나 혹은 디폴트 파라미터를 사용, init 블럭을 사용하여 생성자를 호출할 수 있다. return문을 쓰지 못한다.

부생성자 : constructor 블럭을 사용하여 생성자 호출할 수 있다. return문을 사용할 수 있다. 부생성자 파라미터 목록엔 var/val을 사용할 수 없다.

내포된 클래스 : 클래스 내에 선언할 수 있는 클래스, inner 키워드를 붙이면 자신을 둘러싼 클래스의 인스턴스에 접근할 수 있다.

지역 클래스 : 함수 내에 선언할 수 있는 클래스, 자신을 선언한 블록 내에서만 사용 가능하다.

스마트 캐스트 : 값을 null과 비교하는 분기를 추가하여 절대 null이 될 수 없는 블록을 만든다. 해당 블록에서는 null값이 올 수 없으므로 널관련 연산을 하지 않을 수 있다.

엘비스 연산자 : 널을 대신한 디폴트 값을 지정할 수 있다.

늦은 초기화 : lateinit 키워드를 사용하여 null이 될 수 없는 값을 초기화되기 전까지 .? 나 .!! 연산을 쓰기 싫을 때 사용한다.

isInitialized를 사용하여 변수가 초기화 됐는지 확인할 수 있다. lateinit은 널 아님 단언 연산이기 때문에 잘 사용하지 않는다.

커스텀 접근자 : 프로퍼티 값을 읽거나 쓸 때 호출되는 게터와 세터를 커스텀하여 사용할 수 있다.

지연 계산 프로퍼티 : by lazy 라는 함수를 사용하여 프로퍼티를 읽기 전까지 값에 대한 계산을 미룬다. lazy와 같은 delegate property는 스마트캐스트를 사용할 수 없다.

객체 : object라는 키워드를 사용하여 클래스와 상수를 합한 클래스인 싱글턴을 생성한다. 싱글턴은 인스턴스가 단 하나뿐이다.

동반 객체 : companion object라는 키워드를 사용하여 팩토리 메서드를 만들 수 있다.

객체 식 : 자바의 익명 클래스와 같은 선언 없이 객체를 바로 생성할 수 있는 식이다.

반응형