본문 바로가기

스터디/다재다능 코틀린 프로그래밍

6. 오류를 예방하는 타입 안전성

다재다능 코틀린 프로그래밍 책으로 코틀린 스터디를 진행하면서 발표를 위해 준비했던 글입니다.


Any와 Nothing 클래스

Any

  • Java의 Object와 Kotlin의 Any클래스는 대응되는 클래스지만 Any 클래스에는 확장함수를 통해 들어오는 특별한 메소드들이 많다.
    • 대표적으로 to() 확장함수가 있다.
    • 다양한 Any의 확장함수는 12-5에서 살펴볼 예정
  • Java Object 클래스와 마찬가지로 Any 클래스도 Kotilin의 모든 객체가 상속받으며 제한적으로 사용해야 한다. 타입 안정성을 지키자!

Nothing

  • kotilin에서는 void 대신 Unit을 사용하지만 진짜 아무것도 리턴하지 않는 걸 원할 땐 Nothing을 사용하자.
  • 예외는 발생시킬 수 있다. 예외는 Nothing 타입을 대표한다.
  • 예외로 Int, Double, String 등 모든 클래스를 대체할 수 있는 점은 자바와 똑같다.?
  • Nothing의 유일한 목적은 컴파일러가 프로그램의 타입 무결성을 검증하도록 도와주는 것

Null 가능 참조

  • Optional 사용의 3가지 불리한 점
    1. 컴파일러가 강제하지 않고 개발자가 Optional을 사용해야 한다.
    2. Optional이 객체의 참조 또는 null 참조를 감쌀 때 객체가 없다면 작은 오버헤드가 생긴다.
      1. 무슨 말일까? 객체가 있어도 감쌌다가 풀었다가 할 때 오버헤드가 생기기에 비용이 비싸다고들 하는거 아닌가?
    3. Optionl을 리턴하지 않고 null을 리턴해도 Java 컴파일러는 경고를 주지 않는다.

null은 에러를 유발

  • null을 피해야 하는건 누구나 알고있다. 자바에선 Optional 사용을 권장하지만 Optional을 사용하면서 발생하는 사소한? 문제 또한 코틀린은 우아하게 해결한다.
  • 코틀린은 null 불가참조에 null을 할당하거나 참조타입이 null 불가인 곳에 null을 리턴하려고 하면 컴파일 오류가 난다.
fun nickName(name: String): String {
    if (name == "William") {
        return "Bill";
    }
    return null // ERROR
}
println("${nickName("William")}")
println("${nickName("Venkat")}")
println("${nickName("null)}") // ERROR
  • 리턴타입이 String일 경우 null을 리턴할 수 없고 파라미터 타입이 String인 경우에도 인자로 null을 넘길 수 없다.
  • 반드시 null을 받거나 리턴하거나 하는 상황에선 개발 의도를 아주 명확하게해서 코틀린이 컴파일 시간에 NPE를 발생하지 않게 할 수 있도록 조취를 취해야 한다.

null 가능 타입 사용하기

  • 받는 쪽에서 null 체크를 하지 않으면 null 가능 참조를 사용할 수 없다.
    • 굉장히 섬세하다..,
  • null 가능 타입은 타입 이름 뒤에 ?를 붙인다.
  • 위에서 봤던 코드는 아래와 같이 수정할 수 있다.
fun nickName(name: String): String? {
    if (name == "William") {
        return "Bill";
    }
    return null // 정상 작동
}
println("${nickName("William")}")
println("${nickName("Venkat")}")
// println("${nickName("null)}")
  • null을 인자로 받기 위해서는 똑같이 파라미터 타입 뒤에 ? 를 붙이면 되지만 해당 파라미터 변수를 사용할 땐 무조건 null 체크를 해줘야 한다.
fun nickName(name: String?): String? {
    if (name == "William") {
        return "Bill";
    }
    // return name.reversed() // ERROR
  if (name != null ) {
        return name.reversed()
    }
    return null
}
println("${nickName("William")}")
println("${nickName("Venkat")}")
// println("${nickName("null)}")
  • null 가능 타입을 사용할 때 null 체크를 까먹을 일은 없다. → 안전하지 않은 순간은 존재한다.

세이프 콜 연산자 → ? 연산자

  • ? 연산자를 사용해서 null 체크와 접근을 합칠 수 있다.
  if (name != null ) {
        return name.reversed()
    }
    return null
  • 위 코드를 아래와 같이 바꿀 수 있다.
return name?.reversed()
return name?.reversed()?.toUpperCase() // 이렇게도 가능

엘비스 연산자(Elvis) → ?: 연산자

  • null일 경우에 default 값을 정해줄 수 있음
val result = name?.reversed()?.toUpperCase()
return if (result == null) "Joker" else result
  • null일 때 Joker를 리턴하는 위 코드를 아래와 같이 바꿀 수 있다.
return name?.reversed()?.toUpperCase() ?: "Joker"

사용해선 안될 안전하지 않은 확정 연산자 → !! 연산자

  • not-null 확정 연산자 → !! 연산자
  • !! 연산자는 순수 악이다.
    • null이 아니라고 확신했지만 null이 들어오면 NPE가 터지며 프로그램이 종료된다. gg~
    • 애초에 !!를 그냥 사용하지 말자. 기억에서 지울게요.

when의 사용

  • null 가능 참조로 작업할 때 참조 값에 따라 다르게 동작해야 한다면 when 사용을 고려하자.
fun nickName(name: String?): String? {
    if (name == "William") {
        return "Bill";
    }
    return name?.reversed()?.toUpperCase() ?: "Joker"
}
  • 엘비스 연산자를 사용한 위의 코드를 아래와 같이 바꿀 수 있다.
fun nickName(name: String?) = when (name) {
    "William" -> "Bill"
    null -> "Joker"
    else -> name.reversed().toUpperCase()
}
  • 코틀린은 null로 부터 안정성 뿐만 아니라 우아한 연산자를 제공해 코드의 지저분함도 없애준다. 최고!

타입 체크와 캐스팅

타입 체크

  • 타입을 체크하는 행위는 개방 폐쇄 원칙에 위배된다.
  • 하지만 equals() 메소드를 구현할 때나 인스턴스 타입에 기반해 when 분기를 사용한다면 꼭 필요하다.

is

  • 같은 타입이면 동일하다고 판단하는 equals 메소드 재정의
class Animal {
    override operator fun equals(other: Any?) = other is Animal
}

스마트 캐스트

  • 위에서 살펴봤던 Animal 클래스에 age 라는 인스턴스 변수가 있을 때 equals 메소드 재정의
class Animal(val age: Int) { // 오호.. 생성자 깔끔
    override operator fun equals(other: Any?):Boolean {
        return if (other is Animal) age == other.age else false
    }
}
  • 캐스트없이 other.age를 사용할 수 있다. 코틀린이 other가 Animal 타입인지를 확인했기 때문
  • 아래와 같이 && 연산자로 리팩토링이 가능하다.
class Animal(val age: Int) { // 오호.. 생성자 깔끔
    override operator fun equals(other: Any?) = other is Animal && age == other.age
}

명시적 타입 캐스팅

  • 명시적 타입 캐스팅은 컴파일러가 타입을 확실하게 결정할 수 없어 스마트 캐스팅을 하지 못할 경우에만 사용하자.
    • ex) var 변수가 체크와 사용 사이에서 변경
  • 명시적 타입 캐스팅에는 as와 as? 연산자를 사용한다.
fun fetchMessage(id: Int): Any = 
    if (id == 1) "Record found" else StringBuilder("data not found")

val message1: String = fetchMessage(1) as String
val message2: String? = fetchMessage(1) as? String
  • as 연산자는 캐스팅에 실패하면 프로그램이 죽는다
  • as? 연산자는 캐스팅이 실패하면 null을 할당한다.
  • 결론적으로 as? 연산자가 더 안전하다.
    • null을 할당하기에 엘비스 연산자를 사용하면 적절하게 대응할 수 있다.
fun fetchMessage(id: Int): Any = 
    if (id == 1) "Record found" else StringBuilder("data not found")

println("${(fetchMessage(2) as? String)?.length ?: "---")
// --- 출력
  • 실무 권장사항
    • 가능한 스마트 캐스트를 사용
    • 스마트 캐스트가 불가능한 경우엔 안전한 캐스트 연산자(as?)를 사용
    • 애플리케이션이 망하는 걸 보고싶으면 안전하지 않은 연산자(as)를 사용해라

제네릭: 파라미터 타입의 가변성과 제약사항

타입 불변성

  • 메소드가 클래스 T의 객체를 받을때 T의 하위 객체는 전부 전달할 수 있다.
  • 하지만 T에 제네릭 오브젝트 ex) List 를 받는다면 T의 하위 객체는 전달할 수 없다.
    • List을 T로 넘기면 List는 전달 불가, extends Animal이어도..
fun receiveFruits(fruits: Array<Fruit>) {
    println("${fruits.size}")
}
val bananas1: Array<Banana> = arrayOf()
receiveFruits(bananas1) // ERROR: type mismatch
val bananas2: List<Banana> = listOf()
receiveFruits(bananas2) // 정상 동작
  • 타입 안정적으로 만들었다면서 List는 왜 될까?
  • Array는 뮤터블하다. Array를 넘겨도 Orange 객체가 add 되고 타입이 깨질 수 있다.
  • List는 이뮤터블하다. List를 넘기면 Orange 객체나 다른 객체를 add하거나 변경해서 타입이 깨질 확률 이 없다.
  • 코틀린은 매우 합리적이다..
  • Array, List 의 차이 즉, 코틀린이 불변, 가변 차이점을 전달하는 비밀에는 out 키워드가 있다. 좀 더 알아보자.

공변성 사용하기

  • 코틀린에게 제네릭 파생 타입이 허용되도록 요청해야할 때 타입 프로젝션이 필요하다.
  • 아래와 같은 예제가 그런 경우이다.
fun copyFromTo(from: Array<Fruit>, to: Array<Fruit>) {
    for (i in 0 until from.size) {
        to[i] = from[i]
    }
}
val fruits = Array<Fruit>(3) { _ -> Fruit() }
val bananas = Array<Banana>(3) { _ -> Banana() }
copyFromTo(bananas, fruits) // ERROR: type mismatch
  • 원칙적으론 전달 못하는게 맞지만 from 파라미터는 파라미터의 값을 읽기만 하기 때문에 Fruit 클래스의 하위 타입이 전달돼도 아무런 위험이 없다.
  • 이런 것을 타입이나 파생 타입에 접근하기 위한 파라미터 타입의 공변성이라고 이야기한다.
  • out 키워드로 공변성을 사용하고 에러가 나지않게 고쳐보자.
fun copyFromTo(from: Array<out Fruit>, to: Array<Fruit>) {
    for (i in 0 until from.size) {
        to[i] = from[i]
        // 코틀린에서 out 키워드를 사용한 파라미터를 전달하는 호출이 없는지를 확인한다.
        // from[i] = Fruit() // ERROR
        // from.set(i, to[i]) // ERROR
    }
}
copyFromTo(bananas, fruits) // 정상 동작
  • 이뮤터블한 Array 클래스를 파라미터로 사용하지만 어떤 값도 추가하지 않겠다는 것을 약속하고 공변성을 이용하는 걸 사용 가변성 혹은 타입 프로젝션 이라고 한다.
  • 제네릭 타입을 사용할 때가 아니라 제네릭 클래스를 만들 때 즉 선언할 때 공변성을 사용한다고 지정하는 것을 선언처 가변성이라고 부른다.
    • 선언처 가변성의 좋은 예제가 List 이다.

반공변성 사용하기

  • 베이직 타입(Any)에 공변성을 요청하는 것을 반공변성이라고 한다.
val things = Array<Any>(3) { _ -> Fruit() }
val bananas = Array<Banana>(3) { _ -> Banana() }
copyFromTo(bananas, things) // ERRORL type mismatch
  • 인자 to 자리에 Array를 넣으면 기본 타입 불변성으로 인해 실행이 안된다.
  • 반공변성이 가능하게 요청해보자.
fun copyFromTo(from: Array<out Fruit>, to: Array<in Fruit>) {
    for (i in 0 until from.size) {
        to[i] = from[i]
    }
}
copyFromTo(bananas, things) // 정상 동작
  • copyFromTo 메소드 파라미터 to 자리에 in 키워드가 추가된 것을 확인할 수 있다.
    • 반공변성인 in을 사용해서 사용처 가변성을 주었다.
  • 반공변성은 파라미터 타입을 받을 수만 있고 리턴하거나 다른곳으로 보낼 수 없다.

where을 사용한 파라미터 타입 제한

  • 제네릭 타입에 제약조건을 걸기
fun <T: AutoCloseable> useAndClose(input: T) {
    input.close()
}
  • 여러 개의 제약조건을 넣을 땐 where 키워드를 사용해야 한다.
fun <T> useAndClose(input: T)
where T: AutoCloseable,
T: Appendable {
    input.append("there")
    input.close()
}

스타 프로젝션

  • 스타 프로젝션 <*>은 제네릭 읽기전용 타입과 raw 타입을 위한 코틀린의 기능
  • 타입에 대해 정확히는 몰라도 타입 안정성을 유지하며 파라미터를 전달할 때 사용
fun printValues(values: Array<*>) {
    for (value in values) {
        println(value)
    }
    // values[0] = values[1] // ERROR 읽기만 가능!
}
printValues(arrayOf(1, 2)
// 1
// 2
  • 파라미터를 Array로 작성했다면 ERROR가 발생한 values[0] = values[1] 코드가 컴파일 됐다.
  • 스타 프로젝션은 이런 부주의한 오류를 예방한다.
  • out T와 동일하지만 더 간결하게 작성할 수 있다. (out Fruit 같은게 아니라 out T 이다.)
  • 스타 프로젝션이 선언처 가변성에서 로 정의된 반공분산으로 사용된다면 in Nothing을 사용한 것 과같다. → 읽기만 가능하니까.

구체화된 타입 파라미터

  • 제네릭에서 특정 타입의 첫 번째 인스턴스를 찾는 로직을 자바식으로 코딩하면 아래와 같이 코딩했을 것이다.
fun <T> findFirst(books: List<Book>, ofClass: Class<T>): T {
    val selected = books.filter { book -> ofClass.isInstance(book) }
    if (selected.size == 0 {
        throw RuntimeException("Not Found")
    }
    return OfClass.cast(selected[0])
}
  • 코드가 굉장히 어지럽다. 코틀린의 reified로 리팩토링해보자.
inline fun <reified T> findFirst(books: List<Book>): T {
    val selected = books.filter { book -> book is T }
    if (selected.size == 0 {
        throw RuntimeException("Not Found")
    }
    return selected[0] as T
}
  • reified로 파라미터 타입 T를 선언하면 함수 안에서 T를 타입 체크와 캐스팅용으로 사용 가능하다.
  • 호출하는 코드마저 가독성이 좋아진다.
println(findFirst<NonFiction>(books).name)

'스터디 > 다재다능 코틀린 프로그래밍' 카테고리의 다른 글

8. 클래스 계층과 상속  (0) 2021.11.29
7. 객체와 클래스  (0) 2021.11.29
5. 콜렉션 사용하기  (0) 2021.11.29
4. 외부 반복과 아규먼트 매칭  (0) 2021.11.29
3. 함수를 사용하자.  (0) 2021.11.29