오류를 방해하는 타입 안전성(1)

·2021년 12월 9일
1
post-thumbnail

더 정적인 타입을 가진 언어일수록 타입을 특정하지 않은 상태에서도 기본적인 타입 안전성이 높아진다. 코틀린은 향상된 null 체크, 스마트타입 캐스팅, 유연한 타입 체킹을 이용해서 개발자들의 코드를 더욱 타입 안전적이고 적은 오류를 만들어준다.

코틀린은 컴파일 시간에 NullPointerException을 방지할 수 있도록 도와준다.
코틀린은 Design By Contract 접근방식으로 개발자는 함수나 메소드가 null을 받거나 리턴할 수 있는지 명확하게 표현할 수 있다.

코틀린의 모든 클래스는 Any 클래스에서 상속받는다. Any클래스는 코틀린의 모든 클래스에서 사용가능한 유용한 메소드를 포함하고 있다. 여러 타입을 사용할 때 타입 캐스팅이 필요하다면 코틀린의 스마트 캐스팅 기능이 자동으로 캐스팅해준다.

코틀린은 재네릭 파라미터 타입의 공변성과 반공변성 개념을 통해 Java에서 보다 제네릭을 효율적으로 사용할 수 있다.

📌 Any와 Nothing 클래스


베이스 클래스 Any

코틀린의 모든 클래스는 Any를 상속받았다. Any는 개발자에게 최대한 유연성을 제공한다. 그러니 아주 제한적으로 사용해야만 한다.
Any는 모든 코틀린의 타입에 공통으로 적용되는 메소드를 만들기 위해 존재한다. 예를들면 equals(), hashCode(), toString()같은 메소드는 코틀린의 모든 타입에서 사용 가능하다.

Any는 확장함수를 통해서 특별한 메소드을 제공한다. to() 메소드는 모든 타입의 모든 객체가 사용할 수 있는 Any 타입으로 구성된 Pair를 만들 수 있다.
또한 Any 는 코드블럭을 실행할 때 많은 반복적이고, 장황한 코드들을 제거하기위해 let(), run(), apply(), also() 같은 확장함수를 가지고 있다.

Nothing은 Void보다 강력하다

Java에서는 리턴이 없는 메소드에 void를 사용한다. 코틀린에서는 리턴하지 않을때 void 대신 Unit을 사용한다. 그리고 함수가 절대로 아무것도 리턴하지 않는 상황일때 Nothing을 사용한다.
Nothing은 모든 것을 대표할 수 있다는 유니크한 기능이 있다. Int, Double, String 등 모든 클래스로 대체할 수 있다.

fun computeSqrt(n: Double): Double {
    if (n >= 0) {
        return Math.sqrt(n)
    } else {
        throw RuntimeException("No negative please") //예외는 Nothing 타입을 대표
    }

}

if문은 Double을 리턴하고 else는 예외를 던진다. 예외는 Nothing 타입을 대표한다.
Nothing의 유일한 목적은 컴파일러가 프로그램의 타입 무결성을 검증하도록 도와주는 것이다.

📌 Null 가능 참조


null은 에러를 유발한다

null을 null 불가 참조에 할당하거나 참조타입이 null 불가인 곳에 null을 리턴하려고 하면 컴파일 오류가 난다.

fun nickname(name:String):String{
    if(name=="William"){
        return "Bill"
    }
    return null // ERROR : Null can not be a value of a non-null type String
}

println("Nickname for William is ${nickname("William")}")
println("Nickname for William is ${nickname("Ann")}")
println("Nickname for William is ${nickname(null)}") // ERROR 

코틀린은 리턴타입이 String일 경우 null을 리턴하지 못하게 한다. 이와 유사하게, 파라미터 타입이 String인 경우 함수를 호출할 때 null을 인자로 넘기지도 못하게 한다.
일반적으로 코틀린 코드를 작성할 때 Java와 상호운용할 목적이 아니라면 null과 nullable타입은 절대 사용하지 않는 편이 좋다.

null 가능 타입 사용하기

null 불가 타입들은 각자 대응하는 null 가능 타입이 존재한다.
null 가능 타입은 타입 이름 뒤에 ?가 붙는다. null 불가 타입이 String 이라면 null 가능 타입은 String?이다.

입력

fun nickname(name:String):String?{
    if(name=="William"){
        return "Bill"
    }
    return null
}

println("Nickname for William is ${nickname("William")}")
println("Nickname for William is ${nickname("Ann")}")
//println("Nickname for William is ${nickname(null)}")

💻 출력

Nickname for William is Bill
Nickname for William is null

nickname() 함수에서 참조나 null을 리턴하기 위해서 함수의 리턴타입을 String 에서 String?으로 바꿔주면 된다.

입력

fun nickname(name: String?): String? {
    if (name == "William") {
        return "Bill"
    }
        return name.reversed() // Error : Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?

    return null
}

println("Nickname for William is ${nickname("William")}")
println("Nickname for William is ${nickname("Kris")}")
println("Nickname for William is ${nickname(null)}")

name.reversed() 부분에서 오류가 난다. 코틀린은 null 가능 참조가 메소드를 호출할 때는 세이프콜 연산자 또는 null 아님을 확인해주는 연산자를 요구한다.

fun nickname(name: String?): String? {
    if (name == "William") {
        return "Bill"
    }
        //null체크를 추가한다. 
        if (name != null)
        return name.reversed() 

    return null
}

println("Nickname for William is ${nickname("William")}")
println("Nickname for William is ${nickname("Kris")}")
println("Nickname for William is ${nickname(null)}")

💻 출력

Nickname for William is Bill
Nickname for William is sirK
Nickname for William is null

잘 동작한다. 하지만 null 체크를 하는 코드가 지저분해보인다.
세이프 콜 연산자를 이용하면 좀 더 깔끔한 코드로 만들 수 있다.

세이프 콜 연산자

? 연산자를 세이프 콜 연산자라고 한다.
참조가 null일 경우 세이프 콜 연산자의 결과는 null이다. 참조가 null이 아닐 경우 연산결과는 속성이거나 메소드의 결과가 된다.

fun nickname(name: String?): String? {
    if (name == "William") {
        return "Bill"
    }
        return name?.reversed() 

    return null
}

위에서 if문을 통해 null체크를 해줬지만 세이프 콜 연산자를 이용하면 한 줄로 만들 수 있다.


엘비스 연산자

세이프 콜 연산자는 타깃이 null일 경우에 null을 리턴한다. 만약 null이 아닌 다른걸 리턴하고 싶다면 엘비스 연산자를 이용하면 된다.

fun nickname(name: String?): String {
    if (name == "William") {
        return "Bill"
    }

    val result = name?.reversed()?.uppercase()
    return if (result == null) "Joker" else result
}

위의 코드는 타깃이 null일 경우에 "Joker"를 리턴한다. 코드에서 null체크를 하는 부분을 엘비스 연산자를 통해 줄일 수 있다.

입력

fun nickname(name: String?): String {
    if (name == "William") {
        return "Bill"
    }

    return name?.reversed()?.uppercase() ?: "Joker"
}

println("Nickname for William is ${nicknameElvis("William")}")
println("Nickname for William is ${nicknameElvis("Kris")}")
println("Nickname for William is ${nicknameElvis(null)}")

💻 출력

Nickname for William is Bill
Nickname for William is SIRK
Nickname for William is Joker

사용해서 안될 안전하지 않은 확정 연산자

! ! 연산자는 not-null 확정 연산자이다.
null 가능 타입일 경우 null 체크를 하지 않으면 해당 타입에 대응되는 null 불가 타입의 메소드를 호출 할 수 없었다. 예를 들어, String? 타입의 참조에서는 null 체크를 하지 않으면 reversed() 메소드를 사용할 수 없다.

만약 절대 null이 아니란 사실을 알면 참조가 가능하다. !! 연산자는 null이 아니란걸 확정해준다.

return name!!.reversed().uppercase()

name이 절대 null이 아니라고 확신했지만 만약 null일 경우에는 NullPointerException 오류가 난다.
이런 이유로 ! ! 은 위험한 연산자이기 때문에 사용하지 않는편이 좋다.

when의 사용

null 가능 참조로 작업을 할 때 참조의 값에 따라서 다르게 동작을 해야한다면 ?. 이나 ?: 보다는 when을 사용하는 것이 더 좋다.

세이프 콜이나 앨비스 연산자는 값을 추출해낼 때 사용하고 when은 null 가능 참조에 대한 처리를 결정해야 할때 사용하는것이 바람직하다.

fun nickname(name: String?) = when (name) {
    "William" -> "Bill"
    null -> "Joker"
    else -> name.reversed().uppercase()

}

null 체크를 했기 때문에 when에 있는 모든 다른 경우 전달받은 참조가 null이 아닌 경우에만 동작한다.

📌 타입체크와 캐스팅


타입체크

타입체크는 확장성 측면에서 볼때 최소한으로 해야하지만 실행 시간에 타입 체크를 하는 건 아주 유용하다.

타입 체크가 필요한 두 가지 상황이 있다.

✔ equals() 메소드 구현하는 경우
✔ when의 분기가 인스턴스의 타입에 기반해서 이루어지는 경우

is 사용하기

Object의 equals() 메소드틑 참조 기반 비교이다. 코틀린의 equals()는 동일성을 확인하도록 Any 클래스의 자식인 코틀린의 모든 클래스가 오버라이드했다. 또한 ==연산자를 equals() 대신 사용할 수 있다.

class Animal {
    override operator fun equals(other: Any?) = other is Animal
}

is 연산자는 객체의 참조가 특정 타입을 가르키는지 확인한다. other가 Animal 클래스인지 확인한다.
인스턴스가 예상괸 타입이라면 true를 리턴하고 아닐경우 false를 리턴한다.

val greet: Any = "Hello"
val odie: Any = Animal()
val toto: Any = Animal()
println(odie == greet) //false
println(odie == toto) //true

is 연산자는 모든 타입의 참조에 사용될 수 있다. is 연산자 뒤의 타입이 객체의 타입과 같거나 상속관계에 있다면 true를 리턴한다.

스마트 캐스트

Animal 클래스에 age 속성이 있다고 가정하고 객체를 비교할 때 그 속성을 사용한다고 가정해보자

@Override public boolean equals(Object other) {
    if (other instanceof Animal)
        return age == ((Animal) other).age

    return false;
}

Java에서는 age 속성에 접근하기 위해서 other이 Animal인지 확인하고 그 이후에 다시 캐스팅 후 이용 가능하다.

코틀린은 참조 타입이 확인되면 스마트 캐스팅을 한다.

class Animal(val age: Int) {
    override operator fun equals(other: Any?): Boolean {
        return if (other is Animal) age == other.age else false
    }

}

if문에서 other이 Animal인지 확인했기 때문에 캐스트 없이 바로 other.age 라고 사용할 수 있다.

스마트 캐스트는 코틀린이 타입을 확인하는 즉시 작동하며 if문뿐만이 아니라 || 혹은 && 연산자 이후에도 작동한다.

class Animal(val age: Int) {
    override operator fun equals(other: Any?) =
         other is Animal && age == other.age 
}

이런식으로 리팩토링도 가능하다.

when과 함께 타입 체크와 스마트 캐스트 사용하기

fun whatToDo(dayOfWeek: Any) = when (dayOfWeek) {
    "Saturday", "Sunday" -> "Relax"
    in listOf("Monday", "Tuesday", "Wednesday", "Tursday") -> "Work hard"
    in 2..4 -> "Work hard"
    "Friday" -> "Party"
    is String -> "What, you provided a string of length ${dayOfWeek.length}"
    else -> "No clue"

}

is String -> "What, you provided a string of length ${dayOfWeek.length}"

위의 코드는 전달받은 파라미터가 String인지 확인하기 위해서 is 연산자로 타입체크를 한다.
dayOfWeek는 Any 타입의 파라미터지만 is String에서는 String으로 취급 가능하다. 스마트 캐스팅은 이런식으로 코드를 편리하게 해준다.

📌 명시적 타입 캐스팅


명시적 타입 캐스팅은 컴파일러가 타입을 확실하게 결정할 수 없어 스마트 캐스팅을 하지 못할 경우에만 사용하는게 좋다.

코틀린은 명시적 타입 캐스팅을 위해 2가지 연산자를 제공한다.

✔ as
✔ as?

입력

//다른 타입의 메시지를 리턴해주는 함수
fun fetchMessage(id: Int): Any =
    if (id == 1) "Record found" else StringBuilder("data not found")
    
    for (id in 1..2) {
    println("Message length : ${(fetchMessage(id) as String).length}")
}

💻 출력

Message length : 12
java.lang.ClassCastException: class java.lang.StringBuilder cannot be cast to class java.lang.String (java.lang.StringBuilder and java.lang.String are in module java.base of loader 'bootstrap')
	at chapter6.Unsafecast.<init>(unsafecast.kts:7)

as를 이용해 캐스팅 할 경우 객체의 타입이 예상했던 것과 다를 경우 실행 시간 예외가 발생한다.
이럴 경우, 안전한 대응 연산자인 as?를 사용한다면 문제없이 동작한다.

입력

//다른 타입의 메시지를 리턴해주는 함수
fun fetchMessage(id: Int): Any =
    if (id == 1) "Record found" else StringBuilder("data not found")
    
  for (id in 1..2) {
    println("Message length : ${(fetchMessage(id) as? String)?.length ?: "---"}")
}

💻 출력

Message length : 12
Message length : ---

as 연산자는 캐스팅이 실패하면 죽는데 반해서 안전한 캐스트 연산자인 as?는 캐스팅이 실패하면 null을 반환한다.

위의 코드는 앨비스 연산자를 이용해서 null을 할당할 경우 "---" 을 출력하게 만들었다.
안전 캐스팅 연산자인 as?는 안전하지 않은 as에 비해 훨씬 좋다.

profile
개발하고싶은사람

0개의 댓글