Link
Today
Total
10-16 22:05
Archives
관리 메뉴

초보개발자 긍.응.성

(코틀린 인 액션) 6장 코틀린 타입 시스템 본문

책 정리/코틀린 인 액션

(코틀린 인 액션) 6장 코틀린 타입 시스템

긍.응.성 2022. 8. 3. 01:05
반응형

6장 코틀린 타입 시스템

6.1 널 가능성

널이 될 수 있는 타입

fun strLen(s: String) = s.length
strLen(nullSt)
// ERROR: Null can not be a value of a non-null type String

fun strLenSafe(s: String?): Int =
    if (s != null) s.length() else 0
  • 코틀린에서 타입을 그냥 명시하면 null이 될 수 없는 인자이다.
  • 타입 이름 뒤에 물음표(?)를 붙여 널이 될 수 있는 타입을 명시한다.
  • 널이 될 수 있는 타입의 변수가 있다면 수행할 수 있는 연산이 제한된다.
    • 해당 변수에서 메서드 호출
    • 널이 될 수 없는 변수로 대입
    • 널이 될 수 없는 파라미터로 전달

타입의 의미

타입은 의미로 어떤 값들이 가능한지와 그 타입에 대해 수행할 수 있는 연산의 종류를 결정한다.

자바에서 primitive 타입이 아닌 참조 타입 변수에는 해당 타입의 값과 null이라는 두 가지 종류의 값이 들어갈 수 있다. 두 종류의 값은 완전히 다르며, 실행할 수 있는 연산도 완전히 다르다. 이는 자바의 타입 시스템이 널을 제대로 다루지 못한다는 것을 의미한다. 변수에 선언된 타입이 있지만 널 여부를 추가로 검사하기 전에는 그 변수에 대해 어떤 연산을 수행할 수 있을지 모른다.

코틀린은 널이 될 수 있는 타입과 널이 될 수 없는 타입을 구분한다. 그렇기에 각 타입에 따라 어떤 연산이 가능한지 명확히 이해하고 처리할 수 있다. 또한 코틀린은 널이 될 수 있는 타입에 대해 편하게 다룰 수 있도록 추가적인 연산자를 제공한다.

안전한 호출 연산자: ?.

안전한 호출 연산자는 널이 아닌 값에 대해서만 메서드를 호출한다.
class Employee(val name: String, val manager: Employee?)

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

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 printShippingLabel(person: Person) {
    val address = person.company?.address
        ?: throw IllegalArgumentException("No address")
    with(address) {
        println(streetAddress)
        println("$zipCode $city, $country")
    }
}
  • ?: 연산자를 통해 null대신 사용할 디폴트 값을 지정 가능하다.
  • 코틀린에서는 return, throw 도 식이므로 엘비스 연산자와 조합 가능하다.

안전한 캐스트: as?

타입 캐스트 연산자는 값을 주어진 타입으로 변환하려 시도하고 타입이 맞지 않으면 null을 반환한다.
class Person(val firstName: String, val lastName: String) {
    override fun equals(o: Any?): Boolean {
        val otherPerson = o as? Person ?: return false

        return otherPerson.firstName == firstName &&
                otherPerson.lastName == lastName
    }

    override fun hashCode(): Int =
        firstName.hashCode() * 37 + lastName.hashCode()
}
  • as? 연산자를 통해 값이 원하는 타입인지 검사하고 캐스트 할 수 있다.
  • 엘비스 연산자와 연결해서 원하는 타입이 아닐 때 별도의 처리를 할 수 있다.

널 아님 단언: !!

널 아님 단언을 사용하면 값이 널이 아닐 때 NPE를 던질 수 있다.
fun ignoreNulls(s: String?) {
    val sNotNull: String = s!!
    println(sNotNull.length)
}

ignoreNulls(null)
// Exception in thread "main" kotlin.KotlinNullPointerException
  • !! 연산자를 통해 널이 될 수 없는 타입으로 강제 변환 가능하다.
  • 컴파일러는 !! 연산자를 믿고 컴파일하기에, 실제 null을 참조하는 상황이 있어서 컴파일 타임에 잡히지 않는다.
  • null 값인 경우 KotlinNullPointerException 을 뱉는다.
  • !! 연산자에 대한 예외의 스택 트레이스는 어떤 식에서 발생한 예외인지는 알려주지 않기에 !! 단언문을 연쇄적으로 사용하지 않는 것을 권장한다.

let 함수

let을 안전하게 호출하면 수신 객체가 널이 아닌 경우 람다를 실행해준다.
fun sendEmailTo(email: String) {
    println("Sending email to $email")
}

var email: String? = "ckddn@example.com"
email?.let { sendEmailTo(it) }

email = null
email?.let { sendEmailTo(it) }
  • let 함수는 자신의 수신 객체에 대하여 널이 아닌 경우에만 전달받은 람다로 넘기는 수신 객체 함수이다.
  • 여러 값이 널인지 검사해야 되는 경우라면 let 호출을 중첩하는 것보다 if 문으로 모든 값을 한꺼번에 검사하는 것이 가독성에 더 좋다.

나중에 초기화할 프로퍼티

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

class MyTest {
    private lateinit var myService: MyService

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

    @Test fun testAction() {
        assertEquals("foo", myService.performAction())
    }
}
  • lateinit 키워드를 통해 널이 아닌 프로퍼티를 생성 시점 이후에도 초기화할 수 있다.
  • lateinit 프로퍼티는 final 필드가 아니여야 하므로 항상 var 이다.
  • 프로퍼티를 초기화하기 전에 접근하면 예외가 발생한다.
  • 컴파일 시 가시성은 lateinit 프로퍼티에 지정된 가시성이 그대로 적용된다.

널이 될 수 있는 타입 확장

fun String?.isNullOrBlank(): Boolean =
    this == null || this.isBlank()
fun verifyUserInput(input: String?) {
    if (input.isNullOrBlank()) {
        println("Please fill in the require fields")
    }
}

verifyUserInput(" ")
verifyUserInput(null)
  • 널이 될 수 있는 타입에 대해서도 확장 함수를 정의 가능하다.
    • 함수 내부의 this는 널이 될 수 있으므로 명시적으로 널 체크가 필요하다.
  • 널이 될 수 있는 타입의 확장함수는 안전한 호출 없이도 호출이 가능하다.
val person: Person? = ...
person.let { sendEmail(it) }
  • let은 널이 될 수 있는 타입의 값에 대하여 호출할 수 있지만, this가 널인지는 검사하지 않는다.
  • 그렇기에 let 사용 시 수신 객체가 널인 아닌지 검사하기 위해선 ?. 연산자를 실행해야 한다.

타입 파라미터의 널 가능성

fun <T: Any> printHashCode(t: T) {
    println(t.hashCode())
}

printHashCode(null)
// Type parameter bound for T is not satisfied
printHashCode(42)
// 42
  • 코틀린 타입 파라미터 T 는 이름 끝에 물음표가 없더라도 널이 될 수 있다.
  • 타입 상한(upper bound)을 통해 널이 될 수 없도록 지정할 수 있다.
    • <T: Any> 가 타입 상한이다.

널 가능성과 자바

자바는 널 가능성을 지원하지 않는다. 코틀린은 자바 상호운용성을 위해 자바의 널 가능성 애노테이션을 활용한다.

  • @Nullable 애노테이션이 붙은 TypeType? 으로 취급한다.
  • @NotNull 애노테이션이 붙은 TypeType 으로 취급한다.

코틀린이 이해할 수 있는 널 가능성 애노테이션은 다음과 같다.

  • JSR-305 표준(javax.annotation 패키지)
  • 안드로이드(andriod.support.annotation 패키지)
  • 젯브레인(org.jetbrains.annotations) 등

널 가능성 애노테이션이 없는 경우 자바의 타입은 코틀린의 플랫폼 타입이 된다.

플랫폼 타입

public class Person {
    private final String name;
    // constructor, getter
}
fun yellAt(person: Person) {
    println(person.name.toUpperCase() + "!!!")
}

yellAt(Person(null))
// java.lang.IllegalStateException: person.name must not be null

val s: String? = person.name
val s1: String = person.name

val i: Int = person.name
// Error: Type mismatch: inferred type is String! but Int was expected
// String! 타입에서 ! 표시는 String! 타입의 널 가능성에 대하여 정보가 없다는 것을 뜻한다.
  • 플랫폼 타입은 코틀린이 널 관련 정보를 알 수 없는 타입을 말한다.
  • 모든 연산의 책임은 개발자에게 있으며, 컴파일러는 모든 연산을 허용한다.
  • 코틀린에서 플랫폼 타입을 선언할 수 없다.
  • 대신 자바 프로퍼티를 널이 있는, 널이 없는 타입으로는 선언 할 수 있다.

상속

public interface StringProcessor {
    void process(String value);
}
class StringPrinter: StringProcessor {
    override fun process(value: String) {
        println(value)
    }
}

class NullableStringPrinter: StringProcessor {
    override fun process(value: String?) {
        if (value != null) {
            println(value)
        }
    }
}
  • 자바 인터페이스에 대하여 구현 시 여러 다른 널 가능성으로 구현 가능하다.
  • 널이 될 수 없는 타입으로 선언한 경우 받아온 파라미터에 대하여 !!단언문이 발동된다(StringPrinter의 경우).

6.2 코틀린의 원시 타입

원시 타입: Int, Boolean 등

  • 코틀린은 원시 타입과 래퍼 타입을 구분하지 않는다.
  • 구분이 없기에 숫자 타입이나 원시 타입 값에 메서드를 호출 가능하다.
  • 널이 될 수 없는 타입의 같은 경우 원시 타입으로 컴파일된다.
    • 컬렉션과 제네릭의 경우만 래퍼 클래스로 컴파일된다.

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

  • 널이 될 수 있는 코틀린 타입은 자바의 래퍼 타입으로 컴파일된다.

숫자 변환

val i = 1
val l1: Long = i // type mismatch
val l2: Long = i.toLong()
  • 코틀린은 한 타입의 숫자를 다른 타입의 숫자로 자동 변환하지 않는다.
  • 대신 직접 변환 메서드를 호출해야 한다.
  • 코틀린은 모든 원시 타입에 대한 변환 함수를 제공한다.
fun foo(l: Long) = print(l)
val b: Byte = 1 // 상수 값은 적절한 타입(예제는 byte)으로 해석한다
val l = b + 1L // + 는 다른 두 타입을 인자로 받을 수 있다. 결과는 Long 
foo(42) // 컴파일러에 의해 42는 Long 값으로 해석된다.

Any, Any?: 최상위 타입

  • 코틀린에서는 Any 타입이 모든 널이 될 수 없는 타입의 조상이다.
  • 코틀린은 원시 타입 또한 Any 를 조상 타입으로 갖는다.
  • 널을 포함하는 모든 값을 대입할 변수를 위해선 Any? 타입을 사용해야 한다.
  • Any 타입은 자바 Object로 컴파일 된다.
  • 모든 코틀린 클래스가 갖는 toString, equals, hashCode 메서드 또한 Any 에 들어 있다.
    • java.lang.Object에 존재하는 다른 메서드(wait, notify 등)를 사용하고 싶다면 Object로 값을 캐스트해야한다.

Unit 타입: 코틀린의 void

fun f(): Unit { ... }  // 반환 타입을 명시하지 않은 아래와 같다
fun f() { ... }
  • 코틀린 Unit 타입은 자바의 void와 같은 기능을 한다.
  • 반환 타입 Unit의 코틀린 함수가 제네릭 함수를 오버라이드 하지 않는다면 그 함수는 내부에서 자바 void 함수로 컴파일된다.
  • Unit 타입은 내부에 Unit이라는 단 하나의 값을 가진다.
  • 반환 타입이 Unit인 경우 명시적으로 반환 문을 넣지 않아도 컴파일러가 자동으로 return Unit 을 넣어준다.
  • 자바에서 Void 타입을 반환 타입으로 정할 경우 return null 을 넣어야 하지만 Unit 의 경우 이는 생략 가능하다.

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

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

fail("Error occured")
println("after Nothing") // Unreachable code

---

val address = company.address ?: fail("No address")
// Nothing은 모든 타입을 상속하기에 식으로도 사용가능하다.
  • Nothing 타입은 아무 값도 포함하지 않는다.
  • Nothing 은 함수의 반환 타입이나 반환 타입으로 쓰일 타입 파라미터로만 쓰인다.
  • 컴파일러는 Nothing 이 반환 타입인 함수를 통해 정상 종료되지 않는 부분을 알고, 그 함수를 호출하는 코드를 분석할 때 사용한다.
  • 코틀린의 최하위 클래스이며 모든 타입에 대하여 상속한다.
  • null 값의 타입은 Nothing?이다.

6.3 컬렉션과 배열

널 가능성과 컬렉션

val result = ArrayList<Int?>()
  • 컬렉션 안에 널 값을 넣을 수 있도록 하려면 변수 타입뒤에 ?를 붙여 명시한다.
  • 컬렉션이 널이 될 수 있으면 컬렉션 뒤에 ?를 붙이면 된다.

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

  • 코틀린에서는 컬렉션 안의 데이터에 접근하는 인터페이스와 변경하는 인터페이스를 분리했다.
    • kotlin.collections.Collection - 읽기 전용(RO)
    • kotlin.collections.MutableCollection - 변경 가능(RW)
  • MutableCollectionCollection을 상속하였다.
  • 주의해야 할 사항은 읽기 전용 컬렉션이라고 해서 변경이 불가능하지 않다.
    • 읽기 전용 컬렉션도 thread-safe하지 않다.
  • 자바에서 불변 컬렉션 대하여 수정 시 런타임에 에러가 난다면, 코틀린의 불변 컬렉션은 수정을 위한 메서드를 아예 제공하지 않는다.
add 메서드가 없다!

코틀린 컬렉션과 자바

  • 자바 컬렉션은 모두 코틀린의 MutableCollection을 상속한다.
컬렉션 타입 읽기 전용 타입 변경 가능 타입
List listOf mutableListOf, arrayListOf
Set setOf mutableSetOf, hashSetOf, linkedSetOf, sortedSetOf
Map mapOf mutableMapOf, hashMapOf, linkedMapOf, sortedMapOf
  • 위의 표에서 listOf(), setOf(), mapOf() 은 모두 코틀린 읽기 전용 타입의 인터페이스를 제공하지만, 생성되는 클래스는 변경 가능한 자바 컬렉션 인스턴스를 반환한다.
  • 자바 컬렉션은 RO, RW를 구분하지 않기에 코틀린과 함께 사용한다면 올바른 파라미터 타입을 사용할 책임은 개발자에게 있다.

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

자바 메서드에서 컬렉션 파라미터가 있으며 이를 코틀린으로 구현하고 싶은 경우, 사용된 맥락을 잘 파악하여 알맞게 파라미터 타입을 전환해야 한다.

interface FileContentProcessor {
    void processContent(File path,
        byte[] binaryContent,
        List<String> textContents);
}
class FileIndexer: FileContextProcessor {
    override fun processContent(path: File,
        binaryContent: ByteArray?,
        textContents: List<String>?) {
        // ...
    }
}
  • binary 또는 text로만 들어오기에 널 가능(?)으로 파라미터 선언.
  • 파일의 각 줄은 널이 될 수 없기에 textContent의 원소의 타입은 String.
  • 파일을 내용을 읽기만 할 것이므로 읽기 전용(List)

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

fun main(args: Array<String>) {
    for (i in args.indices) {
        println("Arguments $i is: ${args[i]}")
    }
}
  • 코틀린의 배열은 클래스이다.
  • 배열 생성을 위해 arrayOf, arrayOfNulls, emptyArray 함수를 지원한다.
val letters = Array(26) { i -> ('a' + i).toString() }
println(letters.joinToString(""))
// abcdefghijklmnopqrstuvwxyz
  • 배열 원소의 인덱스를 인자로 받는 람다로 배열을 초기화할 수 있다.
val strings = listOf("a", "b", "c")
println("%s/%s/%s".format(*strings.toTypedArray()))
// vararg 인자를 위해 스프레드 연산자(*) 사용.
  • toTypedArray 메서드를 통해 컬렉션을 배열 타입으로 전환할 수 있다.
  • 원시 타입에 대한 별도의 원시 타입 배열을 지원한다.
    • IntArray, ByteArray, CharArray, BooleanArray
    • 각각 int[], byte[], char[], boolean[] 으로 컴파일된다.
  • 코틀린은 배열에서도 컬렉션에서 사용할 수 있는 확장 함수들을 제공한다. 
반응형
Comments