본문 바로가기
프로그래밍/코틀린

6장. 코틀린 타입 시스템

by 직장인 투자자 2021. 12. 13.

6장. 코틀린 타입 시스템

6.1 널 가능성

  • 널 가능성은 NullPointerException를 피할 수 있게 돕기 위한 코틀린 타입 시스템의 특성.

6.1.1 널이 될 수 없는 타입

  • 널이 될 수 있는 타입은 프로그램 안의 프로퍼티나 변수에 null을 허용하게 만드는 방법이다.
    널이 인자로 들어올 수 없다면 코틀린에서는 다음과 같이 함수를 정의할 수 있다.
fun strLen(s: String) = s.length

이 함수가 널과 문자열을 인자로 받을 수 있게 하려면 타입 이름 뒤에 물음표(?)를 명시해야 한다.

fun strLenSafe(s: String?) = s.length

물음표가 없는 타입은 그 변수가 null 참조를 저장할 수 없다는 뜻이다. 따라서 모든 타입은 기본적으로 널이 될 수 없는 타입이다.

널이 될 수 있는 타입의 변수가 있다면 그에 대해 수행할 수 있는 연산이 제한된다. 예를 들어 널이 될 수 있는 타입인 변수에 대해 변수. 메서드()처럼 메서드를 직접 호출할 수는 없다.

널이 될 수 있는 값을 널이 될 수 없는 타입의 변수에 대입할 수 없다.

널이 될 수 있는 타입의 값을 널이 될 수 없는 타입의 파라미터를 받는 함수에 전달할 수 없다.

널이 될 수 있는 타입의 값은 null 여부를 확인하고 난 뒤에 컴파일러가 그 사실을 기억하고 null이 아님이 확실한 영역에서는 해당 값을 널이 될 수 없는 타입의 값처럼 사용 가능하다.

fun strLenSafe(s: String?) : Int = 
    if (s != null) s.length else 0 -> 널 가능성을 다루기 위해 사용할 수 있는 도구가 if 검사뿐이라면 코드가 번잡해 지겠지만, 코틀린에서는 여러 도구를 제공한다.

6.1.2 타입의 의미

널이 될 수 있는 타입과 널이 될 수 없는 타입을 구분하면 각 타입의 값에 대해 어떤 연산이 가능할지 명확히 이해할 수 있고, 실행 시점에 예외를 발생시킬 수 있는 연산을 판단할 수 있다.

6.1.3 안전한 호출 연산자: ?.

?.은 null 검사와 메서드 호출을 한 번의 연산으로 수행한다. 호출하려는 값이 null이 아니라면 ?.은 일반 메서드 호출처럼 작동한다. 호출하려는 값이 null이면 이 호출은 무시되고 null이 결과 값이 된다.

s?.toUpperCase() 
==
if (s != null) s.toUpperCase() else null

안전한 호출의 결과 타입도 널이 될 수 있는 타입이라는 사실에 유의하라. String.toUpperCase는 String 타입의 값을 반환하지만 s가 널이 될 수 있는 타입인 경우 s?.toUpperCase() 식의 결과 타입은 String?이다.
또, 메서드 호출뿐 아니라 프로퍼티를 읽거나 쓸 때도 안전한 호출을 사용할 수 있다.

fun printAllCaps(s: String?) {
    val allCaps: String? = s?.toUpperCase() // allCaps는 null일 수도 있다.
    println(allCaps)
}

fun main(args: Array<String>) {
    printAllCaps("abc")
    printAllCaps(null)
}
class Employee(val name: String, val manager: Employee?)

fun managerName(employee: Employee): String? = employee.manager?.name

fun main(args: Array<String>) {
    val ceo = Employee("Da Boss", null)
    val developer = Employee("Bob Smith", ceo)
    println(managerName(developer))
    println(managerName(ceo))
}
class Address(val streetAddress: String, val zipCode: Int,
              val city: String, val country: String)

class Company(val name: String, val address: Address?)

class Person(val name: String, val company: Company?)

fun Person.countryName(): String {
   val country = this.company?.address?.country // 안전한 호출 연쇄 사용
   return if (country != null) country else "Unknown" // 이 부분도 불필요한 코드들 추후 수정 가능.
}

//fun Person.countryName() = company?.address?.country ?: "Unknown"

fun main(args: Array<String>) {
    val person = Person("Dmitry", null)
    println(person.countryName())
}

6.1.4 엘비스 연산자: ?:

코틀린은 null 대신 사용할 디폴트 값을 지정할 때 편리하게 사용할 수 있는 엘비스 연산자(?:)를 제공한다.
이 연산자는 이항 연산자로 좌항을 계산한 값이 널 인지 검사한다. 좌항 값이 널이 아니면 좌항 값을 결과로 하고, 좌항 값이 널이면 우항 값을 결과로 한다.

fun strLenSafe(s: String?): Int = s?.length ?: 0

fun main(args: Array<String>) {
    println(strLenSafe("abc"))
    println(strLenSafe(null))
}
class Address(val streetAddress: String, val zipCode: Int,
              val city: String, val country: String)

class Company(val name: String, val address: Address?)

class Person(val name: String, val company: Company?)

fun printShippingLabel(person: Person) {
    val address = person.company?.address
      ?: throw IllegalArgumentException("No address")
    with (address) {
        println(streetAddress)
        println("$zipCode $city, $country")
    }
}

fun main(args: Array<String>) {
    val address = Address("Elsestr. 47", 80687, "Munich", "Germany")
    val jetbrains = Company("JetBrains", address)
    val person = Person("Dmitry", jetbrains)
    printShippingLabel(person)
    printShippingLabel(Person("Alexey", null))
}

6.1.5 안전한 캐스트: 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 // 타입이 서로 일치하지 않으면 false를 반환한다.

      return otherPerson.firstName == firstName && // 안전한 캐스트를 하고나면 otherPerson이 Person 타입으로 스마트캐스트된다.
             otherPerson.lastName == lastName
   }

   override fun hashCode(): Int =
      firstName.hashCode() * 37 + lastName.hashCode()
}

fun main(args: Array<String>) {
    val p1 = Person("Dmitry", "Jemerov")
    val p2 = Person("Dmitry", "Jemerov")
    println(p1 == p2) // == 연산자는 'equals' 메소드를 호출한다.
    println(p1.equals(42))
}

6.1.6 널 아님 단언: !!

느낌표를 이중으로 사용하면 어떤 값이든 널이 될 수 없는 타입으로 바꿀 수 있다. 실제 널에 대해 !!를 적용하면 NPE가 발생한다.

근본적으로 !!는 컴파일러에게 '나는 이 값이 null이 아님을 잘 알고 있다. 내가 잘못 생각했다면 예외가 발생해도 감수하겠다'라고 말하는 것.

fun ignoreNulls(s: String?) {
    val sNotNull: String = s!!
    println(sNotNull.length)
}

fun main(args: Array<String>) {
    ignoreNulls(null)
}

!!를 널에 대해 사용해서 발생하는 예외의 스택 트레이스에는 어떤 파일의 몇 번째 줄인 지에 대한 정보는 들어있지만 어떤 식에서 예외가 발생했는지에 대한 정보는 들어있지 않다. 어떤 값이 널이었는지 확실히 하기 위해 여러 !! 단언문을 한 줄에 함께 쓰는 일을 피하라.

person.company!!.address!!.country // 이런 식으로 코드를 작성하지 말라.

지금까지는 널이 될 수 있는 타입의 값에 어떻게 접근하는지에 대해 주로 살펴봤다. 하지만 널이 될 수 있는 값을 널이 아닌 값만 인자로 받는 함수에 넘기려면 어떻게 해야 할까?

6.1.7 let 함수

let 함수를 안전한 호출 연산자와 함께 사용하면 원하는 식을 평가해서 결과가 널인지 검사한 다음에 그 결과를 변수에 넣는 작업을 간단한 식을 사용해 한꺼번에 처리할 수 있다.

fun sendEmailTo(email: String) { // 이 함수에 널이 될 수 있는 타입의 값을 넘길 수는 없다.
    println("Sending email to $email")
}

fun main(args: Array<String>) {
    var email: String? = "yole@example.com" // 원래는 인자를 넘기기 전에 주어진 값이 널인지 검사해야 하지만 let 함수를 통해 인자를 전달 할 수 있다.
//    sendEmailTo(email)
//    if (email != null) sendEmailTo(email)
    email?.let { sendEmailTo(it) } // let 함수는 이메일 주소 값이 널이 아닌 경우에만 호출된다.
    email = null
    email?.let { sendEmailTo(it) }
}

6.1.8 나중에 초기화 할 프로퍼티

코틀린에서 클래스 안의 널이 될 수 없는 프로퍼티를 생성자 안에서 초기화하지 않고 특별한 메소드 안에서 초기화할 수는 없다. 코틀린에서는 일반적으로 생성자에서 모든 프로퍼티를 초기화해야 한다. 게다가 프로퍼티 타입이 널이 될 수 없는 타입이라면 반드시 널이 아닌 값으로 그 프로퍼티를 초기화해야 한다. 그런 초기화 값을 제공할 수 없으면 널이 될 수 있는 타입을 사용할 수밖에 없다. 하지만 널이 될 수 있는 타입을 사용하면 모든 프로퍼티 접근에 널 검사를 넣거나 !! 연산자를 써야 한다.
lateinit 변경자를 붙이면 프로퍼티를 나중에 초기화할 수 있다.

class MyService {
    fun performAction(): String = "foo"
}

class MyTest {
    private var myService: MyService? = null

    @Before fun setUp() {
        myService = MyService() // setUp 메소드 안에서 진짜 초깃값을 지정한다.
    }

    @Test fun testAction() {
        Assert.assertEquals("foo", myService!!.performAction())
    }
}
class MyService {
    fun performAction(): String = "foo"
}

class MyTest {
    private lateinit var myService: MyService // 초기화 하지 않고 널이 될 수 없는 프로퍼티를 선언한다.

    @Before fun setUp() {
        myService = MyService()
    }

    @Test fun testAction() {
        Assert.assertEquals("foo", myService.performAction())
    }
}

나중에 초기화하는 프로퍼티는 항상 var여야 한다. val 프로퍼티는 final 필드로 컴파일되며, 생성자 안에서 반드시 초기화해야 한다. 따라서 생성자 밖에서 초기화해야 하는 나중에 초기화하는 프로퍼티는 항상 var여야 한다.

6.1.9 널이 될 수 있는 타입 확장

안전한 호출 없이도 널이 될 수 있는 수신 객체 타입에 대해 선언된 확장 함수를 호출 가능하다.
isNullOrEmpty, isNullOrBlank

fun verifyUserInput(input: String?) {
    if (input.isNullOrBlank()) { // 안전한 호출을 하지 않아도 된다.
        println("Please fill in the required fields")
    }
}

fun main(args: Array<String>) {
    verifyUserInput(" ")
    verifyUserInput(null)
}

6.1.10 타입 파라미터의 널 가능성

타입 파라미터 T를 클래스나 함수 안에서 타입 이름으로 사용하면 이름 끝에 물음표가 없더라도 T가 널이 될 수 있는 타입이다. 타입 파라미터가 널이 아님을 확실히 하려면 널이 될 수 없는 타입 상한을 지정해야 한다.

fun <T> printHashCode(t: T) {
    println(t?.hashCode()) // t가 null이 될 수 있으므로 안전한 호출을 써야만 한다.
}

fun main(args: Array<String>) {
    printHashCode(null) // T의 타입은 'Any?'로 추론된다.
}
fun <T: Any> printHashCode(t: T) { // 이제 T는 null이 될 수 없는 타입이다.
    println(t.hashCode())
}

fun main(args: Array<String>) {
    printHashCode(42)
    printHashCode(null) // 컴파일 에러 발생
}

6.1.11 널 가능성과 자바

자바 타입 시스템은 널 가능성을 지원하지 않는데, 자바와 코틀린을 조합하면 어떤 일이 생길까?

자바의 @Nullable String은 코틀린 쪽에서 볼 때 String?와 같고, 자바의 @NotNull String은 코틀린 쪽에서 볼 때 String과 같다. 이런 어노테이션이 소스코드에 없는 경우는 자바의 타입은 코틀린의 플랫폼 타입이 된다.

플랫폼 타입

플랫폼 타입은 코틀린이 널 관련 정보를 알 수 없는 타입을 말한다. 그 타입을 널이 될 수 있는 타입으로 처리해도 되고 널이 될 수 없는 타입으로 처리해도 된다. 이런 경우 컴파일러는 모든 연산을 허용한다.
코틀린에서 플랫폼 타입을 선언할 수는 없다. 자바 코드에서 가져온 타입만 플랫폼 타입이 된다.
자바 API를 다룰 때는 조심해야 한다. 그 메서드가 널을 반환 할지 알아내고 널을 반환하는 메서드에 대한 널 검사를 추가해야 한다.

/* Java */
public class Person {
    private final String name;

    public Person(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}
/* Kotlin */
fun yellAtSafe(person: Person) {
    println(person.name.toUpperCase() + "!!!") // person.name이 null이기 때문에 예외 발생
    println((person.name ?: "Anyone").toUpperCase() + "!!!")
}

fun main(args: Array<String>) {
    yellAtSafe(Person(null))
}

상속

코틀린에서 자바 메서드를 오버라이드 할 때 그 메서드의 파라미터와 반환 타입을 널이 될 수 있는 타입으로 선언할지 널이 될 수 없는 타입으로 선언할지 결정해야 한다.

interface StringProcessor<T> {
  fun process() : T
}
class StringPrinter : StringProcessor {
  override fun process(value: String) {
    println(value)
  }
}

class NullableStringPrinter : StringProcessor {
  override fun process(value: String?) {
    if (value != null) {
      println(value)
    }
  }
}

6.2 코틀린의 원시 타입

6.2.1 원시 타입 : Int, Boolean 등

자바는 원시 타입과 참조 타입을 구분한다.
코틀린은 원시 타입과 래퍼 타입을 구분하지 않으므로 항상 같은 타입을 사용한다.

원시 타입과 참조 타입이 같다면 코틀린이 그들을 항상 객체로 표현하는 걸까? 그렇게 한다면 너무 비효율적이지 않을까? 실제로도 항상 객체로 표현한다면 비효율적이겠지만 코틀린은 그러지 않는다. 대부분의 경우 코틀린의 Int 타입은 자바 int 타입으로 컴파일된다. 이런 컴파일이 불가능한 경우는 컬렉션과 같은 제네릭 클래스를 사용하는 경우뿐이다. 예를 들어, Int 타입을 컬렉션의 타입 파라미터로 넘기면 그 컬렉션에서 Int의 래퍼 타입에 해당하는 java.lang.Integer 객체가 들어간다.

Int와 같은 코틀린 타입에는 널 참조가 들어갈 수 없기 때문에 쉽게 그에 상응하는 자바 원시 타입으로 컴파일할 수 있다. 마찬가지로 반대로 자바 원시 타입의 값은 결코 널이 될 수 없으므로 자바 원시 타입을 코틀린에서 사용할 때도 널이 될 수 없는 타입으로 취급할 수 있다.

6.2.2 널이 될 수 있는 원시 타입: Int?, Boolean? 등

코틀린에서 널이 될 수 있는 원시 타입을 사용하면 그 타입은 자바의 래퍼 타입으로 컴파일된다.

data class Person(val name: String, val age: Int? = null) { // age는 java.lang.Integer로 저장된다.

    fun isOlderThan(other: Person): Boolean? {
        if (age == null || other.age == null) // age는 null 가능성이 있기 때문에 null check가 필요함.
            return null
        return age > other.age
    }
}

fun main(args: Array<String>) {
    println(Person("Sam", 35).isOlderThan(Person("Amy", 42)))
    println(Person("Sam", 35).isOlderThan(Person("Jane")))
}

6.2.3 숫자 변환

코틀린과 자바의 가장 큰 차이점 중 하나는 숫자를 변환하는 방식이다. 코틀린은 한 타입의 숫자를 다른 타입의 숫자로 자동 변환하지 않는다.

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

val i = 1
val l: Long = i.toLong() // 직접 변환 메소드를 호출해야 한다. 

코틀린은 모든 원시 타입에 대한 변환 함수를 제공한다. 양방향 변환 함수가 모두 제공되는데, Int.toLong(), Long.toInt() 모두 존재한다. 범위가 더 좁은 타입으로 변환되면 일부를 잘라낸다.

코틀린은 개발자의 혼란을 피하기 위해 타입 변환을 명시하기로 결정했다. 두 박스 타입 간의 equals 메서드는 그 안에 들어있는 값이 아니라 박스 타입 객체를 비교한다. 따라서 자바에서 new Integer(42).equals(new Long(42))는 false이다.

val i: Int = 42;
val l: Int = 42;
println(i.equals(l)) // true
println(i == l) // true
println(i === l) // true

val i: Int = 42;
val l: Long = 42;
println(i.equals(l)) // false
println(i == l) // 컴파일 오류
println(i === l) // 컴파일 오류

 

fun main(args: Array<String>) {
    val x = 1
    val list = listOf(1L, 2L, 3L)
    x in list // 컴파일 오류 (코틀린에서는 묵시적 타입 변환이 안됨)
}

fun main(args: Array<String>) {
    val x = 1
    println(x.toLong() in listOf(1L, 2L, 3L)) // true (명시적으로 타입 변환을 해줘야 함)
}
fun main(args: Array<String>) {
    val b: Byte = 1 // 상수 값은 적절한 타입으로 해석된다.
    val l = b + 1L // 산술 연산자는 적당한 타입의 값을 받아들일 수 있게 이미 오버로드돼 있다.
    foo(42)
}
fun main(args: Array<String>) {
    println("42".toInt()) // 코틀린에서 toInt, toByte, toBoolean 등과 같이 문자를 원시 타입으로 변환하는 여러 함수를 제공.
}

6.2.4 Any, Any?: 최상위 타입

자바에서 Object가 클래스 계층의 최상위 타입이듯 코틀린에서는 Any 타입이 모든 널이 될 수 없는 타입의 조상 타입이다. Any는 널이 될 수 없는 타입이므로 Any 타입에는 null이 들어갈 수 없다. 코틀린에서 널을 포함하는 모든 값을 대입할 변수를 선언하려면 Any? 타입을 사용해야 한다.

내부에서 Any 타입은 java.lang.Object에 대응한다. 자바 메서드에서 Object를 인자로 받거나 반환하면 코틀린에서는 Any로 취급한다.

모든 코틀린 클래스에는 toString, equals, hashCode라는 세 메서드가 들어있다. 이 세 메서드는 Any에 정의된 메서드를 상속한 것이다. 하지만 java.lang.Object에 있는 다른 메소드(wait, notify 등)는 Any에서 사용할 수 없다. 따라서 그런 메소드를 호출하고 싶다면 java.lang.Object 타입으로 값을 캐스트 해야 한다.

6.2.5 Unit 타입: 코틀린의 void

코틀린 Unit 타입은 자바 void와 같은 기능을 한다.

fun f() : Unit { ... }
==
fun f() { ... }

코틀린의 Unit이 자바 void와 다른 점은 무엇일까? Unit은 모든 기능을 갖는 일반적인 타입이며, void와 달리 Unit을 타입 인자로 쓸 수 있다. Generic type으로 Unit을 지정하면 return을 명시적으로 넣지 않아도 된다. 자바 Void type은 return을 넣어야 하지만 코틀린의 Unit은 return 없이 사용 가능하다는 점이 다르다. 컴파일러가 묵시적으로 return Unit을 넣어준다.

interface Processor<T> {
  fun process() : T
}

class NoResultProcessor : Processor<Unit> {
  override fun process() {
  // return을 명시할 필요가 없다.
  }
}

6.2.6 Nothing 타입: 이 함수는 결코 정상적으로 끝나지 않는다.

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

fun main(args: Array<String>) {
    fail("Error occurred")
}

Nothing 타입은 아무 값도 포함하지 않는다. 따라서 Nothing은 함수의 반환 타입이나 반환 타입으로 쓰일 타입 파라미터로만 쓸 수 있다. 그 외의 다른 용도로 사용하는 경우 Nothing 타입의 변수를 선언하더라도 그 변수에 아무 값도 저장할 수 없으므로 아무 의미도 없다. 컴파일러는 Nothing이 반환 타입인 함수가 결코 정상 종료되지 않음을 알고 그 함수를 호출하는 코드를 분석할 때 사용한다.

6.3 컬렉션과 배열

6.3.1 널 가능성과 컬렉션

변수 타입 뒤에 ?를 붙이면 그 변수에 널을 저장할 수 있다는 뜻인 것처럼 타입 인자로 쓰인 타입에도 같은 표시를 사용할 수 있다.

List<Int?>는 Int? 타입의 값을 저장할 수 있다. = 리스트 안의 각 값이 널이 될 수 있다. 리스트 자체는 항상 널이 아니다.
List<Int>?는 전체 리스트가 널이 될 수 있다. 리스트 안에는 널이 아닌 값만 들어간다.
List<Int?>?는 널이 될 수 있는 값으로 이뤄진 널이 될 수 있는 리스트.

fun readNumbers(reader: BufferedReader): List<Int?> {
    val result = ArrayList<Int?>()
    for (line in reader.lineSequence()) {
        try {
            val number = line.toInt()
            result.add(number)
        }
        catch(e: NumberFormatException) {
            result.add(null)
        }
    }
    return result
}

fun addValidNumbers(numbers: List<Int?>) {
    var sumOfValidNumbers = 0
    var invalidNumbers = 0
    for (number in numbers) { // 리스트의 원소에 접근하면 Int? 타입의 값을 얻는다.
        if (number != null) { // 널 여부 체크 필요
            sumOfValidNumbers += number
        } else {
            invalidNumbers++
        }
    }
    println("Sum of valid numbers: $sumOfValidNumbers")
    println("Invalid numbers: $invalidNumbers")


    val validNumbers = numbers.filterNotNull() // 널이 될 수 있는 값으로 이뤄진 컬렉션으로 널 값을 걸러내는 경우가 자주 있어서 filterNotNull 함수를 제공한다. validNumbers는 List<Int> 타입.
    println("Sum of valid numbers: ${validNumbers.sum()}")
    println("Invalid numbers: ${numbers.size - validNumbers.size}")
}

fun main(args: Array<String>) {
    val reader = BufferedReader(StringReader("1\nabc\n42"))
    val numbers = readNumbers(reader)
    addValidNumbers(numbers)
}

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

코틀린 컬렉션과 자바 컬렉션을 나누는 가장 중요한 특성 하나는 코틀린에서는 컬렉션 안의 데이터에 접근하는 인터페이스와 컬렉션 안의 데이터를 변경하는 인터페이스를 분리했다는 점이다.

이런 구분은 코틀린 컬렉션을 다룰 때 사용하는 가장 기초적인 인터페이스인 kotlin.collections.Collection부터 시작한다. 하지만 Collection에는 원소를 추가하거나 제거하는 메서드가 없다. 컬렉션의 데이터를 수정하려면 kotlin.collections.MutableCollection 인터페이스를 사용해야 한다.

코드에서 가능하면 항상 읽기 전용 인터페이스를 사용하는 것을 일반적인 규칙으로 삼고, 코드가 컬렉션을 변경할 필요가 있을 때만 변경 가능한 버전을 사용하라.

fun <T> copyElements(source: Collection<T>, target: MutableCollection<T>) {
    for (item in source) {
        target.add(item)
    }
}

fun main(args: Array<String>) {
    val source: Collection<Int> = arrayListOf(3, 5, 7)
    val target: MutableCollection<Int> = arrayListOf(1)
    copyElements(source, target)
    println(target)

//    val source: Collection<Int> = arrayListOf(3, 5, 7)
//    val target: Collection<Int> = arrayListOf(1)
//    copyElements(source, target) // 컴파일 오류 발생.
}

컬렉션 인터페이스를 사용할 때 염두에 둬야 할 핵심은 읽기 전용 컬렉션이라고 해서 꼭 변경 불가능한 컬렉션일 필요는 없다는 점이다. 같은 컬렉션 객체를 가리키는 list: List<String>, mutableList: MutableList<Stirng>이 있을 때 이 컬렉션을 참조하는 다른 코드를 호출하거나 병렬 실행한다면 컬렉션을 사용하는 도중에 다른 컬렉션이 그 컬렉션의 내용을 변경하는 상황이 생길 수 있고, 이런 상황에서는 ConcurrentModificationException이나 다른 오류가 발생할 수 있다. 따라서 읽기 전용 컬렉션이 항상 thread safe하지는 않다는 점을 명심해야 한다.

6.3.3 코틀린 컬렉션과 자바

모든 코틀린 컬렉션은 그에 상응하는 자바 컬렉션 인터페이스의 인스턴스라는 점은 사실이다. 따라서 코틀린과 자바를 오갈 때 아무 변환도 필요 없다. 하지만 코틀린은 모든 자바 컬렉션 인터페이스마다 읽기 전용 인터페이스와 변경 가능한 인터페이스라는 두 가지 표현을 제공한다.

자바 메서드를 호출하되 컬렉션을 인자로 넘겨야 한다면 따로 변환하거나 복사하는 등의 추가 작업 없이 직접 컬렉션을 넘기면 된다. 예를 들어 java.util.Collection을 파라미터로 받는 자바 메소드가 있다면 Collection, MutableCollection 값을 인자로 넘길 수 있다. 이런 성질로 인해 자바는 읽기 전용 컬렉션과 변경 가능 컬렉션을 구분하지 않으므로, 코틀린에서 읽기 전용 Collection으로 선언된 객체라도 자바 코드에서는 그 컬렉션객체의 내용을 변경할 수 있다.

/* Java */
// CollectionUtils.java
public class CollectionUtils {
    public static List<String> uppercaseAll(List<String> items) {
        for (int i = 0; i < items.size(); i++) {
            items.set(i, items.get(i).toUpperCase());
        }
        return items;
    }
}
// Kotlin
// collections.kt
fun printInUppercase(list: List<String>) { // 읽기 전용 파라미터 선언.
    println(CollectionUtils.uppercaseAll(list)) // 컬렉션을 변경하는 자바 함수 호출. [A, B, C]
    println(list.first()) // 컬렉션이 변경됐는지 확인. A
}

fun main(args: Array<String>) {
    val list = listOf("a", "b", "c")
    printInUppercase(list)
}

코틀린에서 이를 금지할 방법이 없고, 성능을 포기하지 않고는 컬렉션에 널 값이 들어왔는지 감지할 방법도 없다. 따라서 컬렉션을 자바 코드에게 넘길 때는 특별히 주의를 기울여야 하며, 코틀린 쪽 타입이 적절히 자바 쪽에서 컬렉션에게 가할 수 있는 변경의 내용을 반영하게 해야 한다.

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

자바 코드에서 정의한 타입을 코틀린에서는 플랫폼 타입으로 본다는 사실을 알 것이다. 코틀린 코드는 그 타입을 읽기 전용 컬렉션이나 변경 가능한 컬렉션 어느 쪽으로든 다룰 수 있다. 보통은 동작이 잘 수행될 가능성이 높으므로 실제 문제가 되지는 않는다. 하지만 컬렉션 타입이 시그니처에 들어간 자바 메소드 구현을 오버라이드 하려는 경우 읽기 전용 컬렉션과 변경 가능 컬렉션의 차이가 문제가 된다.

이런 상황에서는 아래 내용을 고려하여 코틀린에서 사용할 컬렉션 타입에 반영해야 한다.

  • 컬렉션이 널이 될 수 있는가?
  • 컬렉션의 원소가 널이 될 수 있는가?
  • 오버라이드 하는 메서드가 컬렉션을 변경할 수 있는가?

자바에서는 같았던 List<String> 타입이 코틀린에서는 상황에 따라 List<String>?, MutableList<String?>와 같이 사용될 수 있다.

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

코틀린에서 배열을 만드는 방법은 다양하다.

  • arrayOf 함수에 원소를 넘긴다.
    ex) val array = arrayOf(1,2)
  • arrayOfNulls 함수에 원소를 넘긴다. 원소 타입이 널이 될 수 있는 타입인 경우에만 이 함수를 쓸 수 있다
    ex) val array = arrayOfNulls(100)
  • Array 생성자를 사용한다.
    ex) val letters = Array(26) { i -> ('a' + i).toString() }

코틀린은 원시 타입의 배열을 표현하는 별도 클래스를 각 원시 타입마다 하나씩 제공한다. 예를 들어 Int 타입의 배열은 IntArray다. 이 모든 타입은 자바 원시 타입 배열인 int [], byte[], char[] 등으로 컴파일된다. 따라서 그런 배열의 값은 박싱 하지 않고 가장 효율적인 방식으로 지정된다.

fun main(args: Array<String>) {
    val strings = listOf("a", "b", "c")
    println("%s/%s/%s".format(*strings.toTypedArray())) // toTypedArray를 사용하면 컬렉션을 배열로 바꿀 수 있다. a/b/c

    val squares = IntArray(5) { i -> (i+1) * (i+1) }
    println(squares.joinToString()) // 1, 4, 9, 16, 25
}
반응형

댓글