코딩하는 개굴이

[Kotlin IN ACTION] 코틀린 타입 시스템 본문

안드로이드/KOTLIN

[Kotlin IN ACTION] 코틀린 타입 시스템

개굴이모자 2021. 4. 11. 03:55
반응형
Kotlin_IN_ACTION_6강_스터디용

Kotlin IN ACTION 6강 : 코틀린 타입 시스템

본 내용은 Kotlin IN ACTION (드미트리 제메로프, 스베트라나 이사코바 지음 / 에이콘 출판사) 책을 기반으로 작성되었습니다.

null 가능성

null 가능성(nullability)은 NullPointerException 오류를 피할 수 있게 돕는 코틀린 타입 시스템의 특성
코틀린에서는 null에 대한 접근 방법으로, null이 될 수 있는지 여부를 타입 시스템에 추가하여 컴파일러가 컴파일 시, 여러 오류를 미리 감지해 실행 시점에 발생할 수 있는 예외 가능성을 줄인다.


null이 될 수 있는 타입

코틀린과 자바의 가장 큰 차이점 중 하나로, 코틀린 타입 시스템이 null이 될 수 있는 타입을 명시적으로 지원하는 것을 꼽을 수 있다.

null이 될 수 있는 타입은 프로그램 안의 프로퍼티나 변수에 null을 허용하게 만들기 때문에, NullPointerException이 발생할 수 있고 안전하지 않다.

코틀린은 그런 메소드의 호출을 금지함으로써 오류를 방지할 수 있다.

  • 자바

    //JAVA
    int strLen(String s) {
        return s.length();
    }
    
    • 자바로 구현된 위 코드는 strLen 함수에 null을 넘겨 s 가 null이 되면 NullPointerException이 발생
      • 함수 내에서 s가 null인지를 검사하는 등의 처리가 필요할 수 있다.
    • 자바에서 NullPointerException 다루기
      • 어노테이션을 사용
        • null이 될 수 있는지 여부를 표시하는 @Nullable, @NotNull 이 존재하나, 일관성 있게 적용된다는 보장을 할 수 없고, 오류가 발생할 위치를 정확히 찾기 쉽지 않다.
      • null 값을 직접 쓰지 않고, 래핑된 Optional 타입 등을 활용
        • 래퍼가 추가되어 실행 시점에 성능이 저하
        • 일관성 있게 활용하기 어려움
        • JDK나 안드로이드 프레임워크, 서드파티 라이브러리 등에서는 null 처리가 필요함
  • 코틀린

    fun strLen(s: String) = s.length
    
    • 코틀린으로 구현된 위 코드에서 strLen 함수의 파라미터 s의 타입은 String으로, null이 될 수 없다.
      • 컴파일러가 null이 될 수 있는 값을 strLen에게 인자로 넘길 수 없도록 막으므로, 실행 시점에 NullPointerException이 발생하지 않음을 보장할 수 있다.
  • 코틀린의 타입

    • 코틀린의 모든 타입은 기본적으로 null이 될 수 없는 타입
    • 모든 타입에 대해 타입 이름 뒤 물음표를 붙이면 해당 타입의 변수나 프로퍼티에 null 참조가 가능함을 의미
    • null이 될 수 있는 타입의 변수는 수행할 수 있는 연산이 제한됨
      • null이 될 수 있는 값을 null이 될 수 없는 타입의 변수에 바로 대입 불가
      • null이 될 수 있는 타입인 변수의 메소드를 직접 호출 불가 (예시> s가 nullable일때 s.length)
      • null이 될 수 있는 값을 null이 될 수 없는 타입의 파라미터를 받는 함수에 전달 불가

안전한 호출 연산자: ?.

  • ?.은 null 검사와 메소드 호출을 한번에 수행
    • s?.strLenif(s != null) s.strLen else null 과 같다.
    • 호출하려는 값이 null이면 호출은 무시되고 null을 결과값으로 준다.
  • null이 될 수 있는 프로퍼티 접근 시 안전한 호출 가능
    • null이 될 수 있는 Employee의 manager 값의 name 조회 시, employee.manager?.name으로 사용 가능 (단, 이때 manager가 null일 경우는 null 리턴)

엘비스 연산자: ?:

엘비스(Elvis) 연산자 혹은 null 복합(null coalescing) 연산자 라고 하며, 간단하게 null 대신 사용할 디폴트 값을 지정 할 수 있다.

TMI로, 엘비스 연산자라는 이름은 90도 시계방향으로 돌렸을 때 엘비스 같은 모습으로 보이는 것에서 유래한듯 하다 ?😃

  • ?:
    • 이항 연산자로, 좌항을 계산한 값이 null인지 검사 후, 좌항의 값이 null이 아닐 경우 좌항 값을 결과로, null이면 우항 값을 결과로 갖는다.
    • 객체가 null인 경우를 대비해 값을 지정
      • s?.strLen ?: 0 s?.strLen이 s가 null 인 것으로 인해 null 이 반환될 경우 0의 값을 지정
    • 함수의 전제 조건 검사
      • return, throw를 엘비스 연산자의 우항에 넣어 전제 조건 검사에 편리
      fun printPersonInfo(person: Person) {
          val birth = person.info?.birth //출생 정보가 여부가 전제 조건
              ?: throw IllegalArgumentException("NO BIRTH")
              with (birth) { //birth는 null 이 아니다
                  ...
              }
              ...
      }
      

안전한 캐스트: as?

자바와 마찬가지로 코틀린의 타입 캐스트도 as로 지정한 타입을 바꿀 수 없으면 ClassCastException이 발생한다.

as를 사용하기 전, is를 통해 미리 as 를 이용해 변환 가능한 타입인지 검사할 수 있겠지만, 더 간결한 as? 연산자를 사용하도록 하자.

  • as?
    • 값을 대상 타입으로 변환할 수 없으면 null을 반환
      • 엘비스 연산자와 함께 사용하는 것이 일반적인 패턴
      class Person (val firstName: String, val lastName: String) {
          override fun equals(o: Any?): Boolean {
              val otherPerson = o as? Person ?: return false //들어온 o의 타입이 Person이 아닐경우 false를 반환
              return otherPerson.firstName == firstName && otherPerson.lastName == lastName //위에서 안전한 캐스트를 한 후이기 때문에, 컴파일러가 otherPerson이 Person으로 스마트 캐스트된다.
          }
      }
      

null 아님 단언: !!

  • null 아님 단언(not-null assertion)은 느낌표를 이중으로 사용하여 어떤 값이든 null이 될 수 없는 타입으로 강제로 바꾼다.
  • 실제 null에 대해 !!를 적용할 경우 Null Pointer Exception이 발생한다.
  • !!는 컴파일러에게 null이 아니며, null 일경우가 생기더라도 예외 발생을 감수하겠음을 말한다.
    • (컴파일러가 검증할 수 없는 단언이기 때문에 코틀린 설계자들이 더 나은 방법을 찾아보라는 의미를 내포하여 못생긴 기호 !!로 정했다고 한다.)
    • 이때 발생한 예외의 stack trace는 몇 번째 줄인지만 보이고, 어떤 식인지 정보는 제공하지 않으므로, 연속적인 단언문을 함께 쓰지 않는 것을 권장
      • person.company!!.address!!.country와 같이 연속적으로 쓰지 않을 것
  • null인지 이미 검사한 후에 다른 함수를 호출하는 등 컴파일러가 안전하게 값을 사용할 수 있다는 것을 인식할 수 없을 경우 사용
    • 단, null이 아닌 값을 전달받는다는 것이 분명할 경우에만 사용할 것을 권장

let 함수

원하는 식의 결과가 null인지 검사한 후, 해당 결과를 변수에 넣는 작업을 간단하게 처리 가능

if (eAddress != null) sendEmailTo(eAddress) //이처럼 구현해야 했던 것을 아래처럼 간단히 처리 가능

eAddress?.let{ sendEmailTo(it) }
  • let 함수는 자신의 수신 객체를 인자로 전달받은 람다에게 넘긴다.

  • let을 위 예시처럼 안전하게 호출하면 수신 객체가 null이 아닌 경우 람다를 실행하고 null인 경우 실행하지 않는다.

  • 값이 null이 아닐 때 수행해야하는 로직에서 사용할 수 있다.

    val receiver: Person? = getRecentContactPerson()
    if(receiver != null) sendEmailTo(reciever.emailAddress)
    
    //아래처럼 간단히 처리 가능
    getRecentContactPerson()?.let { sendEmailTo(it.emailAddress) }
    //getRecentContactPerson()이 null 반환 시, 람다식은 절대 실행되지 않는다.
    
  • 여러 값이 null인지 검사 시, let을 중첩시킬 수 있다.

    • 코드의 복잡도가 높아지기 때문에 if를 사용해 한번에 검사하는 방식을 권장

나중에 초기화할 프로퍼티

객체 인스턴스를 생성한 다음에 나중에 초기화하는 프레임워크와 같은 상황에서 코틀린은 클래스 안의 null이 될 수 없는 프로퍼티를 생성자 안에서 초기화하지 않고, 다른 메소드 안에서 초기화할 수 없다.

(일반적으로 생성자에서 모든 프로퍼티의 초기화가 이루어져야 한다. 그렇기 때문에 초기화를 하거나 null이 될 수 있는 타입을 사용할 수 밖에 없다.)

  • 나중 초기화 (late initialize)
    • lateinit 변경자를 붙여 프로퍼티를 나중에 초기화 할 수 있다.
    • 나중에 초기화하는 프로퍼티는 항상 var 이어야 한다.
      • val 프로퍼티는 final 로 컴파일되며, 생성자 안에서 반드시 초기화해야 한다.
      • 생성자 밖에서 나중에 초기화하는 프로퍼티는 var이어야 한다.
    • 프로퍼티가 초기화 되기 전에 lateinit 변경자가 붙은 프로퍼티에 접근할 경우 **"lateinit property ~ has not been initialized"**라는 예외가 발생하는데, NPE보다 자세한 오류 원인을 알 수 있다.

null이 될 수 있는 타입 확장

지금까지 안전한 호출을 하기 위해 null이 될 수 있는 타입의 값에 물음표를 붙여 str?.strLen의 형태로 함수를 호출하였지만, 다른 방법의 안전한 호출이 가능하다.

  • null이 될 수 있는 타입 확장하기
    • 어떤 메소드를 호출하기 전에 수신 객체 역할을 하는 변수가 null이 될 수 없다고 보장하는 대신, 변수에 대해 메소드를 호출해도 알아서 null을 처리할 수 있도록 확장 함수를 만든다.
    • null이 될 수 있는 타입에 대한 확장을 정의하면 null이 될 수 있는 값에 대해 확장 함수를 호출할 수 있다.
      • 확장하는 함수 내부에서 this는 null이 될 수 있으므로, 명시적으로 null 여부를 검사해야 한다.

      자바에서는 메소드 안의 this는 null이 될 수 없다.
      (메소드를 호출하는 시점에서 NullPointerException이 발생하였을 것이기 때문에 메소드 안으로 들어가지 못함)

      그러나, 코틀린에서는 null이 될 수 있는 타입의 확장 함수에서 this 가 null이 될 수 있다.

    • 추가 검사나 물음표 없이 참조한다고 해서 null이 될 수 없는 타입이라는 것은 아니다.
      • s.isNullOrBlank()와 같이 null이 될 수 있는 타입의 확장함수라면 s는 null이 될 수 있는 타입일 수 있다.
fun verifyInput(input: String?) {
    if (input.isNullOrBlank()) { 
        /*
        input은 null이 될 수 있는 값임에도 함수를 호출하여 안전하지 않아 보이나, 
        isNullOrBlank는 안전한 호출 없이도 null이 될 수 있는 타입에 대해 선언된
        확장 함수이기 때문에 호출할 수 있다.
        */
        println("Required fields are empty")
    }

    /*
    input?.let { //let을 이용해서도 수신 객체가 null이 아닌지 검사할 수 있다.
        if(input.isBlank())
            println("Required fields are empty")
    }
    */
}

fun String?.isNullOrBlank(): Boolean = 
    this == null || this.isBlank()

타입 파라미터의 널 가능성

코틀린에서는 함수나 클래스의 모든 타입 파라미터는 기본적으로 널이 될 수 있기 때문에, 타입 파라미터 T를 클래스나 함수 안에서 타입 이름으로 사용 시, 이름 끝에 물음표가 없더라도 T는 널이 될 수 있는 타입이다.

(타입 파라미터는 코틀린에서 널이 될 수 있는 타입을 표시하기 위해 반드시 물음표를 타입 이름 뒤에 붙여야 한다는 규칙의 유일한 예외이다.)

따라서, 널이 될 수 없게끔 하고자 한다면 타입 상한을 이용해야 한다.

  • 타입 상한(upper bound)
    • 널이 될 수 없는 타입으로의 타입 상한를 지정하여 타입 파라미터가 널이 아님을 확실히 해야한다.
fun <T> printHashCode(t: T) { //T의 타입은 Any? 타입으로 추론됨
    println(t?.hashCode()) //t는 null 이 될 수 있으므로, 안전한 호출을 사용함
}

fun <T: Any> printHashCode(t: T) { //타입 상한을 지정하여 null이 될 수 없는 타입으로 만듬
    println(t.hashCode())
}

null 가능성과 자바

코틀린은 자바 상호운용성을 지원하는 언어이지만, 위에서 언급하였듯 자바 타입 시스템은 nullable을 지원하지 않는 차이가 있다. 자바와 코틀린을 조합하게 된다면 어떻게 될 것인가?

  • 자바 타입을 코틀린에서 사용 시, null 가능성
    • 코틀린은 자바의 어노테이션으로 표시된 nullable에 대한 정보가 있다면 해당 정보를 활용한다.

      • 자바의 @Nullable String은 코틀린에서 String?과 같고, 자바의 @NotNull String은 코틀린에서 String과 같다.
      • 어노테이션이 소스코드에 없는 경우 코틀린의 플랫폼 타입이 된다.
    • 플랫폼 타입 (platform type)

      • 코틀린이 null 관련 정보를 알 수 없는 타입
        • null이 될 수 있는 타입과 null이 될 수 없는 타입 둘다 사용할 수 있다.
      • 코틀린에서 플랫폼 타입을 선언할 수 없다.
        • 자바 코드에서 가져온 타입만 플랫폼 타입이 되며, 컴파일러 오류 메시지에서 String!처럼 ! 표기로 해당 타입의 null 가능성에 대한 정보가 없다는 내용으로 볼 수 있다.
      • 플랫폼 타입에 대해 수행하는 모든 연산에 대한 책임은 자바와 마찬가지로 개발자에게 있다.
        • 플랫폼 타입의 값이 null 이 될 수 있음을 알고 있다면 null 검사를 수행해야 함
        • NPE가 발생

      아래 예시에서 null일 경우 예외가 발생하나, NPE가 아닌 것을 알 수 있다. 코틀린 컴파일러는 public 함수의 null이 아닌 파라미터와 수신 객체에 대한 null 검사를 추가해 주기 때문이다.

      public class Person {
          //어노테이션이 별도로 없기 때문에, 플랫폼 타입이 된다.
          private final String name;
          
          public Person(String name) {
              this.name = name;
          }
      
          public String getName() {
              return name;
          }
      }
      
      fun callPerson(person: Person) {
          println("Hello, " + person.name.toUpperCase()) 
          //person.name이 null일 경우 예외가 발생
          //IllegalArgumentException: Parameter specified as non-null is null: ...
      }
      
      fun callPersonSafe(person: Person) {
          println("Hello, " + (person.name ?: "Nobody")) 
          //자바 클래스 접근에 대한 null 체크
      }
      
    • 자바 메소드를 자세히 보고, 해당 메소드가 null을 반환할지 알아내서 검사를 추가해야한다.

      모든 자바 타입을 nullable한 타입으로 만들었다면 예외가 발생하지 않을 수 있으나, null이 될 수 없는 값에 대해서도 불필요한 검사들이 들어가게 되므로, 개발자에게 책임을 부여하는 지금의 방법을 택하였다고 한다.

    • 코틀린에서 자바 메소드를 오버라이드 시, 해당 메소드의 파라미터와 반환 타입을 nullable로 할 것인지 not-null로 할 것인지 결정해야한다.(둘다 가능하므로, 선택이 된다.)

    • 자바 클래스, 인터페이스를 코틀린에서 구현 시, 코틀린 컴파일러는 null이 될 수 없는 타입으로 선언한 모든 파라미터에 대해 null이 아님을 검사하는 단언문 !!를 만들어 준다.

      • 따라서 자바 코드에서 코틀린에서 구현한 메소드에 null을 넘기면 단언문에서 예외가 발생한다.

코틀린의 원시 타입

원시 타입

  • 자바의 원시 타입
    • 자바는 원시 타입과 참조 타입을 구분
      • 원시 타입(primitive type)의 변수에는 그 값이 그대로 들어가고, 참조 타입(reference type)의 변수에는 메모리상의 객체 위치가 들어간다.
      • 원시타입으로 메소드를 호출하거나 컬렉션에 원시 타입 값을 담을 수 없다.
        • 참조 타입이 필요한 경우 래퍼 타입(java.lang.Integer 등)으로 원시 타입 값을 감싸서 사용한다.
          • Collection<int>가 아닌, Collection<Integer>로 사용한다.
  • 코틀린의 원시 타입
    • 코틀린은 원시 타입과 참조 타입을 구분하지 않고 항상 같은 타입을 사용
      • 항상 객체로 표현하는 것이 아니라, 실행 시점에 자바의 원시 타입 혹은 래퍼 타입으로 컴파일된다.
        • Int 타입은 대부분 자바 int 타입으로 컴파일 된다.
        • Int 타입을 컬렉션의 타입 파라미터로 넘길 경우 컬렉션에는 java.lang.Integer 객체가 들어가게 된다.
      • 자바의 원시 타입은 null이 될 수 없고, 코틀린의 Int타입 또한 null참조가 들어갈 수 없기 때문에 상응하는 원시 타입으로 컴파일 할 수 있다.

널이 될 수 있는 원시 타입

  • null이 될 수 있는 코틀린 타입은 자바의 래퍼 타입으로 컴파일된다.
    • null이 될 수 있는 코틀린 타입은 null 참조 자체가 자바의 참조 타입 변수에만 대입 가능하기 때문에 자바의 원시 타입으로 표현할 수 없다.
  • 제네릭 클래스의 경우 래퍼 타입을 사용한다.
    • 클래스의 타입 인자로 원시 타입을 넘길 경우 코틀린은 해당 타입에 대한 박스 타입을 사용한다.
      • val listOfInts = listOf(1,2,3)처럼 null 값이나 null이 될 수 있는 타입을 사용하지 않았어도, 만들어지는 리스트는 래퍼 Integer로 이루어진 리스트이다.
      • JVM에서 제네릭 클래스는 항상 박스타입을 사용하며, 타입 인자로 원시 타입을 허용하지 않기 때문

숫자 변환

코틀린은 자바와 달리 한 타입의 숫자를 다른 타입의 숫자로 자동 변환해 주지 않는다.
대신, 모든 원시 타입에 대한 양방향 변환 함수를 제공하므로 이를 호출해야 한다.

val i = 1
val l: Long = i // Type mismatch 컴파일 오류가 발생

// 직접 변환 메소드 호출
val i = 1
val l: Long = i.toLong()
  • 변환 함수
    • 박스 타입을 비교하는 경우, equals 메소드는 각 값이 아닌 박스 타입 객체를 비교
      • 자바의 new Integer(13).equals(new Long(13))은 false인 것과 같이 코틀린에서도 예상치 못한 동작을 피하기 위해 각 변수를 명시적으로 변환해 주어야한다.
    • 숫자 리터럴을 사용할 경우 대부분 변환함수를 호출할 필요가 없다.
      • 숫자 리터럴을 type이 명확한 변수에 대입하거나 함수에 인자로 넘기면 컴파일러가 필요한 변환을 자동으로 넣어준다.

      코틀린의 산술 연산자에서도 숫자 연산 시 overflow가 발생할 수 있음을 주의하자.


Any, Any?: 최상위 타입

코틀린에서는 Any가 원시 타입을 포함한 모든 타입의 조상 타입이다.
Any는 null이 될 수 없는 타입이므로, 만일 null을 포함하는 모든 값을 대입할 변수 선언 시 Any? 타입을 사용해야한다.

Any타입은 java.lang.Object에 대응한다.
(자바에서는 참조 타입들만 Object를 조상 타입이기에 원시 타입은 포함되지 않으나, 코틀린은 모든 타입의 조상 타입이다.)


Unit 타입: 코틀린의 void

코틀린 Unit 타입은 자바 void와 같은 기능을 한다.
반환 타입의 선언 없이 정의한 블록과 동일하다.

//둘다 동일하다
fun f(): Unit {...}
fun f() {...}
  • Unit 타입과 자바 void와의 차이점
    • Unit은 모든 기능을 갖은 일반적인 타입으로, void와 달리 Unit을 타입 인자로 사용할 수 있다.
    • Unit 타입의 함수는 묵시적으로 Unit 값을 반환한다.

  • 위 특성으로, 제네릭 파라미터를 반환하는 함수를 오버라이드하면서 반환 타입으로 Unit을 쓸 때 유용하다
    • 컴파일러가 묵시적으로 return Unit을 넣어주기 때문에 명시적으로 Unit을 반환할 필요가 없다.
interface Processor<T> {
    fun process() : T //인터페이스는 해당 함수가 어떤 값을 반환할 것을 요구한다
}

class NoResultProcesser: Processor<Unit> {
    override fun process() {
        ... //명시적으로 Unit 값을 반환할 필요는 없다.
    }
}

Nothing 타입

Nothing 타입은 아무 값도 포함하지 않기 때문에, 함수의 반환 타입이나 반환 타입으로 쓰일 타입 파라미터로만 쓸 수 있다.

테스트 라이브러리들은 Nothing을 반환 타입으로 가지는 fail이라는 함수를 제공하여 테스트를 실행시키는 등 함수가 정상적으로 끝나지 않았다는 것을 알리기 위해 사용한다.

fun fail(msg: String): Nothing {
    throw IllegalStateException(message)
}

val address = company.address ?: fail("No Address")

컬렉션과 배열

null 가능성과 컬렉션

변수 타입 뒤에 ?를 붙이면 해당 변수에 null을 저장할 수 있다는 뜻인 것처럼 컬렉션의 타입 인자로 쓰인 타입도 같은 표시를 사용해 표현할 수 있다.

  • 컬렉션의 null 가능성
    • 변수 타입의 널 가능성과 타입 파라미터로 쓰이는 타입의 널 가능성 비교
      • 널이 될 수 있게 하고자 하는 것이 컬렉션의 원소인지 컬렉션 자체인지 주의해야한다.
        • List<Int?> : 널이 될 수 있는 int로 이루어진 리스트
        • List<Int>? : Int로 이뤄진 null이 될 수 있는 리스트
        • List<Int?>?: null이 될 수 있는 값으로 이루어진 null이 될 수 있는 리스트
          • 변수에 대한 null 검사를 수행한 후, 해당 리스트에 속한 모든 원소에 대해 다시 null 검사를 수행해야한다.
    • null이 될 수 있는 값으로 이루어진 리스트 다루기
      • 리스트의 원소는 null 이 될 수 있는 값이기 때문에 null 여부를 검사 후, 사용해야 한다.
      • null이 될 수 있는 값으로 이루어진 컬렉션에서 null값을 걸러내는 경우, filterNotNull이라는 함수를 이용할 수 있다.
        • 컬렉션 안에 null이 없음을 보장하기 때문에 null이 될 수 없는 타입이된다.
fun addValidNumbers(numbers: List<Int?>) { //null이 될 수 있는 Int 값으로 이루어진 리스트를 만든다
    var sumOfValidNumbers = 0
    var countOfInvalidNumbers = 0

    for(n in numbers) {
        if(n != null) {
            sumOfValidNumbers += n
        } else {
            countOfInvalidNumbers++
        }
    }
}

//filterNotNull을 이용해 간단히 만들기
fun addValidNumbers(numbers: List<Int?>) {
    val validNumbers = numbers.filterNotNull() //컬렉션에서 null값들을 걸러내기 때문에 List<Int>타입이 된다.
    var sumOfValidNumbers = validNumbers.sum()
    var countOfInvalidNumbers = numbers.size - validNumbers.size
}

읽기 전용과 변경 가능한 컬렉션

코틀린 컬렉션과 자바 컬렉션의 큰 차이점 중 하나는, 코틀린에서는 컬렉션 안의 데이터에 접근하는 인터페이스(kotlin.collections.Collection)와 컬렉션 안의 데이터를 변경하는 인터페이스(kotlin.collections.MutableCollection)를 분리하였다는 것이다.

  • 코틀린의 컬렉션 인터페이스
    • 컬렉션을 사용 시, 코드에서 가능하면 항상 읽기 전용 인터페이스를 사용할 것을 권장한다.
    • 어떤 함수가 MutableCollection을 인자로 받는다면 해당 함수가 컬렉션의 데이터를 바꿀 것이라 가정할 수 있다.
      • 원본의 변경을 막기 위해 경우에 따라 해당 함수로 컬렉션을 전달 시, 원본의 변경을 막기 위해 컬렉션을 복사하는 방어적 복사 패턴(defensive copy)을 사용해야 할 수도 있다.
    • 읽기 전용 컬렉션이라고 해서 변경 불가능한 컬렉션을 보장할 수 없다.
      • 읽기 전용 인터페이스 타입인 변수 사용 시, 해당 인터페이스는 실제로 어떤 컬렉션 인스턴스를 가리키는 변경 가능한 인터페이스 타입 참조가 함께 있을 수 있다.
        • 읽기 전용 컬렉션이 항상 thread safe 하지 않을 수 있다.
          • 다중 스레드 환경에서는 해당 데이터를 적절히 동기화 하거나 동시 접근을 허용하는 데이터 구조를 활용해야한다.

코틀린 컬렉션과 자바

모든 코틀린 컬렉션은 그에 상응하는 자바 컬렉션 인터페이스의 인스턴스이기 때문에 코틀린과 자바를 오갈 때 변환이 필요없다. 코틀린은 모든 자바 컬렉션 인터페이스마다 위에서 언급하였듯, 읽기 전용 인터페이스와 변경 가능한 인터페이스 두가지 표현을 제공한다.

  • 코틀린 컬렉션 인터페이스의 계층 구조
    • 변경 가능한 인터페이스들은 읽기 전용 인터페이스를 상속한다.
      • 읽게 전용 인터페이스들에는 컬렉션을 변경할 수 있는 모든 요소들이 빠져있다.
    • 변경 가능한 인터페이스들은 java.util 패키지에 있는 인터페이스들과 직접적으로 연결된다.
      • 코틀린에서는 java.util 패키지에 있는 인터페이스들이 코틀린의 변경 가능한 인터페이스를 상속한 것처럼 취급한다.

  • 컬렉션을 코틀린에서 자바로 넘기는 경우
    • 자바 메소드를 호출해서 컬렉션을 인자로 넘겨야 하는 경우 따로 변환할 필요 없이 직접 컬렉션을 넘길 수 있다.
      • 자바는 읽기 전용 컬렉션과 변경 가능 컬렉션을 구분하지 않기 때문에, 코틀린에서 읽기 전용으로 선언되었다고 하더라도 자바에서 해당 내용을 변경할 수 있다.
      • 자바 코드가 컬렉션을 변경할지 여부에 따라 올바른 파라미터 타입을 사용할 책임은 개발자에게 있다.

컬렉션을 플랫폼 타입으로 다루기

앞서 자바 코드에서 정의한 타입을 코틀린에서 플랫폼 타입으로 보게 되고, 코틀린에서는 널 관련 정보를 알 수 없기에 컴파일러는 해당 타입을 널이 될 수 있는 타입과 될 수 없는 타입 둘다 사용할 수 있도록 허용한다고 하였다. 컬렉션도 마찬가지로 자바에서 선언한 컬렉션 타입 변수를 코틀린에서도 플랫폼 타입으로 보게 된다.

  • 자바 코드에서 선언된 컬렉션을 코틀린에서 다루기
    • 컬렉션 타입이 자바 메소드 구현을 오버라이드 하려는 경우
      • 읽기 전용 컬렉션과 변경 가능 컬렉션의 차이로, 오버라이드 하려는 메소드의 자바 컬렉션 타입을 어떤 코틀린 컬렉션 타입으로 표현할지 결정이 필요하다.
      • 자바 인터페이스나 클래스가 코틀린에서 어떤 맥락으로 사용되는지 정확히 파악하고 아래 내용을 고려하여 결정한다.
        1. 컬렉션이 null이 될 수 있는가?
        2. 컬렉션의 원소가 null이 될 수 있는가?
        3. 오버라이드 하는 메소드가 컬렉션을 변경할 수 있는가?

아래 자바 코드에 대해 코틀린으로 메소드 구현을 오버라이드 할 경우의 컬렉션 타입 표현을 결정 해 보자.
/*
인터페이스를 구현한 클래스는 아래 항목을 만족해야 한다.
- 텍스트 폼에서 읽은 데이터를 파싱해 객체 리스트를 생성
- 만든 리스트의 객체들을 출력 리스트에 추가
- 데이터를 파싱하는 과정에서 발생한 오류 메시지를 별도 리스트에 넣어 보고
*/
interface DataParser<T> {
    void parseData(String input, 
        List<T> output,
        List<String> errorMessages);
}
/*
1. output 컬렉션과 error 컬렉션은 호출하는 쪽에서 항상 리스트를 받아야 하므로, null이 될 수 없다.

2. errorMessages의 원소는 output에 들어가는 정보를 파싱 시, 오류가 발생하지 않으면 null이 들어갈 수 있다.

3. output 컬렉션은 객체들이 추가될 수 있어야 하므로, 변경 가능해야 한다.
   errors 컬렉션은 파싱하는 과정에서 발생한 오류 메시지를 추가해야 하므로, 변경 가능해야한다.
*/

class PersonParser: DataParser<Person> {
    override fun parsePerson(input: String,
        output: MutableList<Person>,
        errors: MutableList<String?>) {
            ...
    }
}

객체의 배열과 원시 타입의 배열

코틀린에서는 기본적으로 배열보다 컬렉션을 우선적으로 사용할 것을 권장하지만, 배열을 인자로 받는 자바 함수를 호출하거나, vararg 파라미터를 받는 코틀린 함수 호출 등에서 배열을 사용해야 하는 경우가 생긴다.

  • 코틀린 배열
    • 배열의 원소가 될 타입 파라미터를 받는 클래스이다.
    • 배열 만드는 방법
      • arrayOf 함수에 원소를 넘긴다.
      • arrayOfNulls 함수에 배열의 크기가 될 정수 값을 넘겨 모든 원소가 null인 배열을 생성한다.
      • Array 생성자에 배열 크기와 람다를 인자로 주어 각 배열의 원소를 초기화한다.
      //Array 생성자가 배열 크기 26을 주고, 람다는 각 배열 원소의 인덱스를 인자로 받아 해당 위치에 들어갈 원소를 반환
      val letters = Array<String>(26) {i -> ('a' + i).toString()}
      println(letters.joinToString(" "))
      //출력: a b c d e f g h i j k l m n o p q r s t u v w x y z
      
    • 컬렉션을 배열로 변환
      • toTypedArray메소드를 사용하여 컬렉션을 배열로 바꿀 수 있다.
      val strings = listOf("2021", "04", "12")
      println("%s/%s/%s".format(*strings.toTypedArray()))//vararg 인자를 넘기기 위해 스프레드 연산자 *를 사용
      //출력 : 2021/04/12
      
    • 배열 타입의 타입 인자도 제네릭 타입처럼 객체 타입이 된다.
      • Array<Int> -> 박싱된 정수의 배열인 java.lang.Integer[]가 된다.
      • 박싱하지 않은 원시 타입의 배열이 필요 시, 특별한 배열인 원시 타입 배열 클래스를 사용해야 한다.
    • 원시 타입 배열 클래스
      • 모든 원시 타입에 대해 동일하게 하나씩 제공된다. (편의상, Int로 통일하여 설명)
      • Int타입의 원시 타입 배열 클래스는 IntArray이며, 자바 원시 타입 배열인 int[]로 컴파일된다.
      • 원시 타입 배열 생성 방법
        • 각 배열 타입의 생성자로 size를 넘겨서 해당 원시 타입의 디폴트 값으로 초기화된 size 크기의 배열을 생성
          • val fizeZeros = IntArray(5) //디폴트 값이 보통 0이다.
        • 팩토리 함수로 여러 값을 가변 인자로 넘겨 배열을 생성
          • val fiveZerosToo = intArrayOf(0, 0, 0, 0, 0)
        • 람다를 인자로 받는 생성자 사용
          • val squares = IntArray(5) {i -> (i+1) * (i+1)}
        • 박싱된 값이 들어있는 컬렉션이나 배열을 toIntArray의 변환함수를 이용해 원시 타입 배열로 변환할 수 있다.
      • 원시 타입인 원소로 이뤄진 배열도 컬렉션에서 사용할 수 있는 모든 확장함수를 동일하게 사용할 수 있다.
        • 단, 함수들은 배열이 아닌 리스트를 반환한다.

참고 링크

반응형
Comments