Study/다재다능 코틀린 프로그래밍

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

주지민 2021. 11. 2. 23:48
반응형

본 포스트는 지인들과 스터디한 내용을 정리한 포스트입니다

 

http://www.kyobobook.co.kr/product/detailViewKor.laf?mallGb=KOR&ejkGb=KOR&barcode=9788931463422

 

다재다능 코틀린 프로그래밍 - 교보문고

다양한 프로그래밍 패러다임으로 살펴보는 코틀린 기본서 | 코틀린은 멀티패러다임 프로그래밍 언어다. 코틀린은 스크립트로 사용할 수도 있고, 객체지향 코드나 함수형 코드, 비동기 프로그램

www.kyobobook.co.kr


1. 오류를 예방하는 타입 안정성

  • 코틀린의 디자인 바이 컨트랙트 접근방식으로 개발자는 함수나 메서드가 null을 받거나 리턴할 수 있는지 명확하게 표현할 수 있으며, 그 시점도 알 수 있다
  • 신 기능들은 컴파일 시점에 체크를 하므로 성능에 전혀 영향이 없다
  • 코틀린의 모든 클래스는 Java의 Object 클래스처럼 Any 클래스에서 상속을 받는다
  • Any 클래스는 코틀린의 모든 클래스에서 사용 가능한 유용한 메서드를 포함하고 있다

 

2. Any와 Nothing 클래스

  • 코틀린의 Any 클래스는 Java의 Object에 대응되는 클래스
  • Nothing 클래스는 함수가 아무것도 리턴하지 않을 경우 리턴하는 클래스

2-1. 베이스 클래스 Any

  • 코틀린의 모든 클래스는 Any를 상속받는다
  • 함수가 여러 타입의 객체를 파라미터로 받는다면, 함수의 파라미터를 Any로 설정해 놓으면 된다
  • 이와 유사하게 특정 타입을 리턴하기 어렵다면 Any를 리턴하면 된다
  • Any 클래스는 개발자에게 최대한 유연성을 제공하기 때문에, 아주 제한적으로 사용해야만 한다 ( 과한 기능 )
  • equals(), hashCode(), toString() 같은 메서드들은 코틀린의 모든 타입에서 사용 가능 ( Any에 구현되어 있기 때문에 )
  • Object와 비슷하지만 Any는 더 많은 확장 함수를 가지고 있다 ( let(), run(), apply(), also() 등등 )

2-2. Nothing은 void보다 강력하다

  • 코틀린에서는 표현식이 리턴을 하지 않을때 void대신 Unit을 사용한다
  • 함수 자체가 정말 아무것도 리턴하지 않을 때 Nothing 클래스를 사용한다
  • Nothing 클래스는 인스턴스가 없고 값이나 결과가 영원히 존재하지 않을 것이라는 걸 나타낸다
  • 예외는 Nothing 타입을 대표한다 ( 컴파일러가 프로그램의 무결성을 검증하도록 도와주는 역할 )

 

3. Null 가능 참조

  • 함수에서 null을 리턴하게 되면 호출하는 쪽에서 null 체크가 강제된다. 이를 체크안할 경우 NullPointException이 발생
  • 최근 Java에서는 Optional을 통해 이슈를 해결해 나가고 있지만 몇가지 불리한 점이 있다
    • 개발자가 Optional을 사용해야한다 ( 컴파일러가 Optional을 강제하지 않는다 )
    • Optional이 객체의 참조 또는 null을 감쌀 때 객체가 없다면 작은 오버헤드가 생긴다
    • 개발자가 Optional이 아닌 null을 리턴해도 컴파일러는 아무 경고를 주지 않는다
  • 이를 해결하기 위해 코틀린은 안전하고 편리한 nullable 참조를 디레퍼런스할 수 있게 하는 연산자를 제공
  • 일반적으로 코틀린 코드를 작성할 때 Java와 상호운용할 목적이 아니라면 null과 nullable 타입은 절대 사용하지 않는 편이 좋다
  • null 불가 타입들은 각자 대응하는 null 가능 타입이 존재한다 ( String의 null 가능 타입은 String? )

3-1. 세이프 콜 연산자

  • ?연산자를 이용하면 메소드 호출 또는 객체 속성 접근과 null 체크를 하나로 합칠 수 있다
  • ?연산자를 세이프 콜 연산자라고 한다
  • 참조가 null일 경우에 세이프 콜 연산자의 결과는 null이다
  • 참조가 null이 아닐 경우 연산결과는 속성이거나 메서드의 결과가 된다
  • 예제코드
    // safecalloperator.kts
    // not checked null
    fun example(name: String?): String? {
        if (null != name) {
            return name.reversed()
        }
        return null
    }
    
    // safe call operator
    fun example2(name: String?): String? {
        return name?.reversed()?.uppercase()
    }
    
    
    println(example(null)) // null
    println(example2(null)) // null​

3-2. 엘비스 연산자

  • 세이프 콜 연산자는 타깃이 null일 경우에 null을 리턴한다
  • 엘비스 연산자 ?: 을 이용하면 좌측 표현식의 결과가 null이 아닐 경우 결과를 리턴해주고 null일 경우 우측 표현식의 결과를 리턴해준다
    // example
    return name?.reversed()?.upperCase() ?: "Joker"​

3-3. 확정 연산자 !!

  • not-null 확정 연산자 '!!' , 조커 연산자라고 불리기도 한다
  • 사용해서는 안되는 안전하지 않은 연산자이다
  • 코틀린에서는 null 가능 타입일 경우 null 체크를 하지 않으면 해당 타입에 대응되는 null 불가 타입의 메서드를 호출할 수 없었다
  • 하지만 특정 참조가 절대 null이 아니란 사실을 알고 있다면 코틀린에게 쓸데없는 체크를 하지말라는 키워드를 전달할 수 있는데 이때 사용하는 것이 확정 연산자 '!!'이다
  • 당연히 NULL이 반환되면 NullPointException이 나기 때문에 절대 사용하지 말자
    // example
    return name!!.reversed().toUpperCase()​

3-4. when의 사용

  • null 가능 참조로 작업을 할 때 참조의 값에 따라서 다르게 동작하거나 다른 행동을 취해야 한다면 '?.'(세이프 콜) 이나 '?:'(엘비스) 보다는 when을 사용하는 것을 고려해보자
  • 즉 세이프 콜이나 엘비스 연사나는 값을 추출해낼 때 사용하고 when은 null 가능 참조에 대한 처리를 결정해야 할 때 사용하도록 하자
  • 예제 코드
    // when.kts
    // 세이프 콜 + 엘비스 조합
    fun nickName(name: String?): String {
        if (name == "William") {
            return "Bill"
        }
        return name?.reversed()?.uppercase() ?: "Joker"
    }
    
    
    // when
    fun nickName2(name: String?) = when (name) {
        "William" -> "Bill"
        null -> "Joker"
        else -> name.reversed().uppercase()
    }
    
    
    println(nickName(null)) // "Joker"
    println(nickName2(null)) // "Joker"
    • when이 표현식으로 쓰였다
    • else절이 가장 마지막에 나와야하며 null에 해당하는 절은 else 위면 어디든 상관없다
    • null 체크를 했기 떄문에 when에 있는 다른 모든 경우 전달받은 참조가 null이 아닌 경우에만 동작

 

4. 타입 체크와 캐스팅

  • 타입 체크
    • 코드의 확정성 측면에서 봤을 때 타입체크는 최소한으로만 해야한다
    • 임의의 타입을 체크하는 행위는 새로운 타입이 추가됐을 때 코드를 부서지기 쉽게 만들고 개방-폐쇄 원칙에도 위배된다
    • 실행 시간에 반드시 타입체크해야하는 상황
      • equals() 메서드를 구현할 때, 현재 가지고 있는 객체가 해당 클래스의 인스턴스인지 알아야하기 때문에
      • when을 사용할 때 when의 분기가 인스턴스의 타입에 기반해서 이루어지는 경우 타입체크가 필요하다
  • is 사용하기
    • Object의 equals() 메서드는 참조 기반 비교이다, 하지만 클래스에서 equals() 메서드를 참조(동등성)이 아닌 동일성을 확인하도록 오버라이드했을 수도 있다
    • 코틀린의 Any 클래스(모든 클래스의 부모)의 자식인 코틀린의 클래스는 equals() 메서드를 오버라이드 했다
    • 코틀린은 '==' 연산자를 equals() 메서드에 매핑해놨다
    • is 연산자는 객체가 참조로 특정 타입을 가리키는지 확인한다 ( instanceof )
    • 예제 코드
      // equals.kts
      class Animal {
          override operator fun equals(other: Any?) = other is Animal
      }
      
      val greet: Any = "hello"
      val odie: Any = Animal()
      val toto: Any = Animal()
      println(odie == greet) // false
      println(odie == toto) // true​
    • 참조 타입을 비교하려면 '==='
    • is 연산자는 모든 타입의 참조에 사용될 수 있다
      • 참조가 null일 경우 is의 연산결과는 false 이다
      • is 연산자 뒤의 타입이 객체의 타입과 같거나 상속관계에 있다면 true를 리턴한다
    • is 연산자는 '!'키워드와 조합하여 부정을 나타낼 수도 있다
  • 스마트 캐스트
    • 코틀린은 참조의 타입이 확인되면 자동 혹은 스마트 캐스팅을 한다
    • 예제코드
      // smartcast.kts
      class Animal(val age: Int) {
          override operator fun equals(other: Any?): Boolean {
              return if (other is Animal) age == other.age else false
          }
      }
      
      val odie = Animal(2)
      val toto = Animal(2)
      val butch = Animal(3)
      println(odie == toto) // true
      println(odie == butch) // false​

      • is 연산자로 체크를 했기 때문에 캐스트를 할 필요가 없다
      • 스마트 캐스트는 코틀린이 타입을 확인하는 즉시 작동한다
      • if문 뿐만 아니라 '||', '&&' 연산자 이후에도 작동한다
      • 위 코드를 이런식으로도 바꿀 수 있다
        // smartcase.kts
        
        class Animal2(val age: Int) {
            override operator fun equals(other: Any?) = other is Animal2 && age == other.age
        }
        
        
        val odie = Animal2(2)
        val toto = Animal2(2)
        val butch = Animal2(3)
        println(odie == toto) // true
        println(odie == butch) // false​
    • 코틀린은 스마트 캐스트가 가능하다면 자동으로 스마트 캐스트를 해준다
    • 참조의 타입이 특정 타입이란 것이 확인되면 캐스트를 할 필요가 없다 ( ex) 객체가 null 참조가 아니라고 판별하면 null 가능 타입이 null 불가 타입으로 자동으로 캐스팅된다 )
  • when과 함께 타입 체크와 스므타 캐스트 사용하기
    • when 명령문 또는 표현식에 is나 !is, 스마트 캐스팅을 사용할 수 있다
    • 예제코드
      // whattodo.kts
      fun whatToDo(dayOfWeek: Any) = when (dayOfWeek) {
          "Saturday", "Sunday" -> "Relax"
          in listOf("Monday", "Tuesday", "Wednesday", "Thursday") -> "Work hard"
          in 2..4 -> "Work hard"
          "Friday" -> "Party"
          is String -> "What?"
          else -> "No clue"
      }
      
      println(whatToDo("Saturday")) // Relax
      println(whatToDo("Monday")) // Work hard
      println(whatToDo(2)) // Work hard
      println(whatToDo("Friday")) // Party
      println(whatToDo("hihihihi")) // What?​
      • when에서 사용되는 조건 중 하나는 실행 중에 전달받은 파라미터가 String인지 확인하기 위해서 is 연산자로 타입 체크를 하는 것

 

5. 명시적 타입 캐스팅

  • 명시적 타입캐스팅은 컴파일러가 타입을 확실하게 결정할 수 없어 스마트 캐스팅을 하지 못할 경우에만 사용하도록 하자
    • 예를 들어 var 변수가 체크와 사용 사이에서 변경되었다면 코틀린은 타입을 보장해줄 수 없다
    • 이런 케이스는 스마트 캐스팅을 적용할 수 없음
  • 코틀린은 명시적 타입 캐스트를 위해서 2가지 연산자를 제공
    • as
      • 우측에 지정된 타입과 같은 타입을 결과로 준다
      • 캐스팅이 실패하면 프로그램을 종료시킨다 ( as로 지정한 타입이 명확해야함 )
    • as?
      • null 가능 참조 타입을 결과로 가진다
      • 캐스팅이 실패하면 null을 반환한다
  • 타입 캐스팅 권장사항
    • 가능한 스마트 캐스팅을 사용하라
    • 스마트 캐스팅이 불가능한 경우에만 안전한 캐스팅 연산자(as?)를 사용하라

 

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

  • 코드 재사용에 대한 욕구가 타입 안정성을 저하시켜서는 안된다 => 해결책: 제네릭
  • 제네릭을 사용하면 다양한 타입에서 사용 가능한 코드를 만들 수 있다
  • 동시에 컴파일러는 제네릭 클래스 또는 함수가 의도하지 않은 타입에서 사용되는지를 검증할 수 있다
  • Java에서의 제네릭
    • 타입 불변성
    • 제네릭 함수가 파라미터 타입 T를 받는다면 T의 부모 클래스나 자식 클래스를 사용하는 것이 불가능
    • <? extend T>, <? super T> 를 통해 공변성을 사용하지만, 제네릭을 사용할때만 가능하고 선언할때는 불가능하다 ( ??? 뭔말인지 이해못했다... )
  • 타입 불변성
    • 메소드가 타입 T의 제네릭 오브젝트를 받는다면, 즉 List<T>, T의 파생 클래스(자식, 부모)를 전달할 수 없다
    • 예를 들어 List<Animal>을 전달할 수는 있지만 Dog extends Animal 이더라도 List<Dog>를 전달할 수 없다, 이러한 성격을 타입 불변성이라고 한다
    • 상속이란 대체 가능성을 의미
      • 자식 클래스의 인스턴스는 부모 클래스의 인스턴스를 인자로 하는 모든 메서드에 전달할 수 있다
      • 예제코드
        // receiveFruits.kts
        open class Fruit
        class Banana: Fruit()
        class Orange: Fruit()
        
        fun receiveFruits(fruites: Array<Fruit>) {
            println("Number of fruits: ${fruites.size}")
        }
        
        fun receiveFruits2(fruites: List<Fruit>) {
            println("Number of fruits: ${fruites.size}")
        }
        
        //val bananas: Array<Banana> = arrayOf();
        //receiveFruits(bananas) // type mismatch
        
        var bananas: List<Banana> = listOf();
        receiveFruits2(bananas) // 0 ( Success )​
      • Array<T>를 파라미터로 받는 메서드에 T의 자식 클래스를 전달하면 "type mismatch" 오류가 발생했다, Array<T>는 뮤터블이기 때문
      • List<T>를 파라미터로 받는 메서드에는 T의 자식 클래스가 그대로 전달됐다. List<T>는 이뮤터블이기 때문이다
  • 공변성 사용하기
    • 공변성: 특정 타입의 객체를 다른 타입의 객체로 변환할 수 있는 성격 
    • 코틀린 컴파일러가 공변성을 허용해서 제네릭 베이스 타입이 요구되는 곳에 제네릭 파생 타입이 허용되도록 하기 위해 타입 프로젝션이 필요하다
    • 예제코드
      // copy.kts
      open class Fruit
      class Banana : Fruit()
      class Orange : Fruit()
      
      fun copyFromTo(from: Array<Fruit>, to: Array<Fruit>) {
          for (i in 0 until from.size) {
              to[i] = from[i]
          }
      }
      
      val fruitsBasket1 = Array<Fruit>(3) { _ -> Fruit() }
      val fruitsBasket2 = Array<Fruit>(3) { _ -> Fruit() }
      copyFromTo(fruitsBasket1, fruitsBasket2) // no problem
      
      
      val fruitsBasket3 = Array<Fruit>(3) { _ -> Fruit() }
      val fruitsBasket4 = Array<Banana>(3) { _ -> Fruit() }
      copyFromTo(fruitsBasket3, fruitsBasket4) // type mismatch​

      • 코틀린은 Array<Fruit> 자리에 Array<Banana>를 전달하지 못하도록 막는다 ( Array<T> 타입은 변경할 수 없다 )
      • copyFromTo 함수의 from 변수는 파라미터의 값을 읽기만 하기 때문에 Array<T>의 객체로 Fruit 클래스나 Fruit 클래스의 하위 클래스가 전달되더라도 아무런 위험이 없다
    • 공변성 파라미터 타입 사용
      • 예제코드
        // copyout.kts
        open class Fruit
        class Banana : Fruit()
        class Orange : Fruit()
        
        fun copyFromTo(from: Array<out Fruit>, to: Array<Fruit>) {
            for (i in 0 until from.size) {
                to[i] = from[i]
            }
        }
        
        val bananaBasket = Array<Banana>(3) { _ -> Banana() }
        val fruitsBasket = Array<Fruit>(3) { _ -> Fruit() }
        copyFromTo(bananaBasket, fruitsBasket) // no probleam​

        • from[i] = Fruit() 혹은 from.set(i, to[i])와 같이 from 파라미터를 수정하는 호출이 있다면 컴파일이 실패한다
      • Array<T> 클래스는 T타입의 객체를 읽고, 쓰는 메서드 모두 가지고 있다
      • 하지만 공변성을 사용하기 위해서 Array<T> 파라미터에서 어떤 값도 추가하거나 변경하지 않겠다는 약속을 해야 한다
      • 이런 제네릭 클래스를 사용하는 관점에서 공변성을 이용하는 걸 사용처 가변성 혹은 타입 프로젝션이라고 부른다
      • 공변성을 사용하면 컴파일러에게 자식 클래스를 부모 클래스의 자리에 사용할 수 있게 요청할 수 있다
  • 반공변성 사용하기
    • in 키워드는 메소드가 파라미터에 값을 설정할 수 있게 만들고, 값을 읽을 수 없게 만든다
    • 예제코드
      // copyin.kts
      open class Fruit
      class Banana : Fruit()
      class Orange : Fruit()
      
      
      fun copyFromTo(from: Array<out Fruit>, to: Array<in Fruit>) {
          for (i in 0 until from.size) {
              to[i] = from[i]
          }
      }
      
      val bananaBasket = Array<Banana>(3) { _ -> Banana() }
      val things = Array<Any>(3) { _ -> Fruit() }
      copyFromTo(bananaBasket, things)​
    • <in T>로 정의되면 전체적으로 파라미터 타입을 받을 수만 있고 리턴하거나 다른 곳으로 보낼 수는 없는 반공변성으로 특정된다
  • where를 사용한 파라미터 타입 제한
    • 제네릭은 파라미터에 여러 타입을 쓸 수 있도록 유연함을 제공해준다
    • 여러 타입을 사용할 수 있지만 제약조건이 필요한 경우도 있다
    • 예제코드
      // closeerr.kts
      //fun <T> useAndClose(input: T) {
      //    input.close() // unresolved reference: close
      //}
      
      
      fun <T: AutoCloseable> useAndClose(input: T) {
          input.close() // no problem
      }​
    • 하나의 제약조건을 넣기 위해서 파라미터 타입 뒤에 콜론을 넣은 후 제약조건을 정의하면 된다
    • 여러개의 제약조건을 걸기 위해서는 where를 사용해야한다
    • 예제코드
      // where.kts
      fun <T> useAndClose(input: T)
              where T : AutoCloseable,
                    T : Appendable {
          input.append("there")
          input.close()
      }
      
      val writer = java.io.StringWriter()
      writer.append("hello")
      useAndClose(writer)
      println(writer) // hellothere​
  • 스타 프로젝션
    • Java는 개발자가 raw 타입을 직접 만들 수 있다 ( ex. ArrayList )
    • 하지만 raw 타입을 직접 만드는 것은 일반적으로 타입 안정성이 없고 가급적 하지 말아야 할 일이다
    • 파라미터 타입을 정의하는 스타 프로젝션<*>은 제네릭 읽기전용 타입과 raw타입을 위한 코틀린 기능이다
    • 즉 스타 프로젝션은 타입에 대해 정확히는 알 수 없지만 타입 안정성을 유지하면서 파라미터를 전달할 때 사용된다
    • 읽는 것만 허용되고, 쓰는 것은 허용되지 않는다 ( out T와 동일하지만 더 간결하게 작성할 수 있다 )

 

7. 구체화된 타입 파라미터

  • Java에서 제네릭을 사용할 때 Class<T>를 함수에 파라미터로 전달해야 하는 지저분한 코드를 볼 수있다
  • 코틀린은 구체화된 타입 파라미터를 이용해서 해당 문제를 해결했다
  • 예제코드: Java 스타일의 장황한 코드버전
    // reifiedtype.kts
    
    abstract class Book(val name: String)
    class Fiction(name: String) : Book(name)
    class NonFiction(name: String) : Book(name)
    
    val books: List<Book> = listOf(
        Fiction("Moby Dick"),
        NonFiction("Learn to Code"),
        Fiction("LOTR")
    )
    
    
    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])
    }
    
    println(findFirst(books, NonFiction::class.java).name) // Learn to Code​

    • 바이트코드로 컴파일되면서 파라미터 타입 T가 지워지기 때문에 함수 안에서 T를 book is T나 selected[0] as T처럼 연산자와 함께 사용할 수 없다
  • 함수가 호출될 때마다 프로그래머는 실행 시간 타입 정보를 추가적인 아규먼트로 전달해야만 한다
  • 이는 호출하는 쪽과 받아주는 쪽 모두에게 나쁜 코드를 만들게 된다
  • 코틀린은 실행시간에 사용할 수 없는 파라미터 타입을 reified라고 마크하고 함수가 inline으로 선언되었다면 사용할 수 있도록 권한을 준다
  • 개선코드
    // reifiedtype_after.kts
    abstract class Book(val name: String)
    class Fiction(name: String) : Book(name)
    class NonFiction(name: String) : Book(name)
    
    val books: List<Book> = listOf(
        Fiction("Moby Dick"),
        NonFiction("Learn to Code"),
        Fiction("LOTR")
    )
    
    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
    }
    
    println(findFirst<NonFiction>(books).name) // Learn to Code​
    • 파라미터 타입 T를 reified로 선언하고 Class<T> 파라미터를 제거했다
    • 함수 안에서 T를 타입 체크와 캐스팅용으로 사용 가능하다
    • 함수가 inline으로 선언되어 있기 때문에(reified는 inline 함수에서만 사용 가능하다) 함수의 바디가 함수 호출하는 부분에서 확장된다
    • 코드가 확장될 때 타입 T는 컴파일 시간에 확인되는 실제 타입으로 대체된다
    • 구체화된 타입 파라미터는 클러터를 지우는 데 유용하고, 잠재적인 코드의 오류를 제거하는 데에도 도움이 된다
    • 또한 추가적인 타입 파라미터 정보를 전달하지 않도록 만들어주고, 코드에서 캐스팅을 안전하게 하는 데 도움을 준다 
728x90
반응형