도서, 강의/코틀린 쿡북

[코틀린 쿡북] 2장 코틀린 기초

제이온 (Jayon) 2022. 4. 8.

코틀린에서 널 허용 타입 사용하기

널 할당이 불가능한 변수 선언

var name: String
name = "Dolly" (o)
name = null (x)

 

널 허용 변수 선언

class Person(val first: String, val middle: String?, val last: String) {

    override fun toString(): String {
        return "Person(first='$first', middle=$middle, last='$last')"
    }
}

fun main(args: Array<String>) {
    val p = Person(first = "god", middle = null, last = "hyunwoo")
    println(p)
}

// 실행 결과
Person(first='god', middle=null, last='hyunwoo')

 

val 변수의 널 허용성 검사하기

class Person(val first: String, val middle: String?, val last: String) {

    override fun toString(): String {
        return "Person(first='$first', middle=$middle, last='$last')"
    }
}

fun main(args: Array<String>) {
    val p = Person(first = "god", middle = null, last = "hyunwoo")
    if (p.middle != null) {
        val middleNameLength = p.middle.length // 영리한 타입 변환
        println(middleNameLength)
    }
}

 

if문은 middle 속성이 null이 아닌 값을 가지고 있는지 확인하고, middle 값이 null이 아니라면 마치 p.middle 의 타입을 String? 타입 대신 String 타입으로 처리하는 영리한 타입 변환을 수행한다.

 

var 변수가 널 값이 아님을 단언하기

class Person(val first: String, val middle: String?, val last: String) {

    override fun toString(): String {
        return "Person(first='$first', middle=$middle, last='$last')"
    }
}

fun main(args: Array<String>) {
    var p = Person(first = "god", middle = null, last = "hyunwoo")
    if (p.middle != null) {
        // val middleNameLength = p.middle.length // 영리한 타입 변환 불가
        val middleNameLength = p.middle!!.length // null이 아님을 단언
        println(middleNameLength)
    }
}

 

var 타입을 쓰게 되면, p의 middle 속성에 접근하기 전에 값이 변경될 수 있으므로 영리한 타입 변환을 수행하지 않는다. 만약 영리한 타입 변환을 수행하고 싶다면 null이 아님을 단언하는 ! ! 연산자를 사용해야 한다. !! 연산자는 해당 값이 null이 아니라고 믿고, null이면 NullPointerException을 던진다. 이 연산자는 코틀린에서 NullPointerException을 만날 수 있는 몇 안 되는 상황 중 하나이므로 사용하지 않는 것을 권장한다.

 

안전 호출 연산자 사용하기

fun main(args: Array<String>) {
    var p = Person(first = "god", middle = null, last = "hyunwoo")
    val middleNameLength = p.middle?.length
    println(middleNameLength)
}

// 실행 결과
null

 

그래서 위와 같이 if문으로 묶고 그 안에 !! 연산자를 사용하기 보다는, 안전 호출 연산자(?.)를 사용한다. 안전 호출 연산자는 값이 null이면 예외를 발생하지 않고 null을 돌려준다. 만약 middleNameLength 타입을 Int?으로 사용하고 싶다면, 아래와 같이 엘비스 연산자(?:)와 병행하여 사용한다.

 

안전 호출 연산자와 엘비스 연산자

fun main(args: Array<String>) {
    var p = Person(first = "god", middle = null, last = "hyunwoo")
    val middleNameLength = p.middle?.length ?: 0
    println(middleNameLength)
}

// 실행 결과
0

 

엘비스 연산자는 자신의 왼쪽에 위치한 식의 값을 확인해서 null이 아니면 그 값을 돌려주고, null이면 초기 값인 오른쪽 값을 넘겨 준다.

 

안전 타입 변환 연산자

val p1 = p as? Person

안전 타입 변환 연산자는 타입 변환이 올바르게 동작하지 않는 경우 ClassCastException을 방지한다. 예를 들어 위 p 변수의 타입이 Person이라면 Person 타입을 반환하고, 그렇지 않으면 null을 반환한다. 그리고 null이 반환되었을 때 엘비스 연산자(?:)를 통해 초기 값으로 적절하게 변환할 수도 있다.

 

정리

  • ?를 통해 null 할당이 가능한 변수를 선언할 수 있다. (ex. String?) ?가 없으면 null 할당이 불가능하다.
  • val 타입 객체의 필드 값은 영리한 타입 변환이 가능하지만, var 타입 객체의 필드 값은 영리한 타입 변환이 불가능하다.
  • !!. 연산자는 해당 값이 null이 아님을 단언하고, null이라면 NullPointerException을 일으킨다.
  • ?. 연산자는 해당 값이 null이면 null을 반환한다.
  • ?: 연산자는 해당 값이 null이면 : 오른쪽 항에 있는 초기 값을 반환한다.
  • as? 연산자는 타입 변환이 올바르지 않을 경우 ClassCastException 대신 null을 반환한다.

 

자바에 널 허용성 지시자 추가하기

널 허용 타입과 비허용 타입

var s: String = "Hello, World!" // null이 될 수 없다.
var t: String? = null // 타입에 있는 물음표는 널 허용 타입을 나타낸다.

 

만약 이 코드를 자바 코드와 상호 작용하고 싶다면, gradle 빌드 파일에 JSR-305 호환성을 강제해야 한다.

 

Gradle에서 JSR-305 호환성 강제하기

// 그루비 DSL
sourceCompatibility = 1.8
compileKotlin {
    kotlinOptions {
        jvmTarget = "1.8"
        freeCompilerArgs = ["-Xjsr305=strict"]
    }
}
compileTestKotlin {
    kotlinOptions {
        jvmTarget = "1.8"
        freeCompilerArgs = ["-Xjsr305=strict"]
    }
}

// 코틀린 DSL
tasks.withType<KotlinCompile> {
    kotlinOptions {
        jvmTarget = "1.8"
        freeCompilerArgs = ["-Xjsr305=strict"]
    }
}

 

자바를 위한 메소드 중복

기본 파라미터가 정의된 코틀린 함수

fun addProduct(name: String, price: Double = 0.0, desc: String? = null) =
    "Adding product with $name, ${desc ?: "None" }, and " + NumberFormat.getCurrencyInstance().format(price)

fun main(args: Array<String>) {
    println(addProduct("jayon"))
    println(addProduct("jayon", 500.0))
    println(addProduct("jayon", 500.0, "chai"))
}

// 실행 결과
Adding product with jayon, None, and ₩0
Adding product with jayon, None, and ₩500
Adding product with jayon, chai, and ₩500

 

addProduct() 함수는 문자열 name이 필수지만, 나머지는 필수가 아니고 기본 값이 정해져 있다. 그래서 필수 인자만 넣어 주고, 나머지는 선택적으로 넣어 줄 수 있다.

참고로 Optional이나 Null 허용 속성은 함수 시그니처의 마지막에 위치시켜야, 위치 인자를 사용해서 함수를 호출할 때 Optional이나 Null 허용 속성을 생략할 수 있다.

 

자바에서 addProduct() 함수 호출하기

@Test
void addProduct() {
    System.out.println(XXX.addProduct("jayon", 500.0, "chai"));
}

 

하지만 자바는 메소드 기본 인자를 제공하지 않으므로 모든 인자를 제공해야 한다. 만약 코틀린의 기본 파라미터가 정의된 코틀린 함수를 온전하게 호출하고 싶다면, addProduct() 함수에 @JvmOverloads 어노테이션을 추가하면 된다. 자바 입장에서 @JvmOverloads가 붙은 메소드는 아래와 같은 형태가 된다.

 

public String addProduct(String name, double price, String desc)
public String addProduct(String name, double price)
public String addProduct(String name)

 

중복된 생성자를 갖는 코틀린 클래스

data class Product @JvmOverloads constructor(
    val name: String,
    val price: Double = 0.0,
    val desc: String? = null
)

fun main(args: Array<String>) {
    println(Product("jayon"))
    println(Product("jayon", 500.0))
    println(Product("jayon", 500.0, "chai"))
}

// 실행 결과
Adding product with jayon, None, and ₩0
Adding product with jayon, None, and ₩500
Adding product with jayon, chai, and ₩500

 

@JvmOverloads 에노테이션을 추가하려면 constructor 키워드를 명시적으로 사용해야 한다. 이제 생성자 하나 만으로도 여러 개의 인자를 받을 수 있다. 참고로, data class는 데이터 보관 목적으로 만드는 클래스로, toString(), hashCode(), equals(), copy() 메소드를 자동으로 생성해 준다.

@JvmOverloads를 사용하면 생성자 자체를 가능한 경우의 수에 대해서 모두 만들어준다는 것을 잊지 말자.

 

명시적으로 타입 변환하기

자바에서 타입 변환

int myInt = 3;
long myLong = myInt; // 짧은 기본 타입을 긴 기본 타입으로 승격이 가능

Integer myInteger = 3;
Long myWrappedLong = myInteger; // 컴파일 x
Long myWrappedLong = myInteger.longValue(); // long으로 추출한 다음 래퍼 타입으로 감쌈
myWrappedLong = Long.valueOf(myInteger); // 래퍼 타입을 벗겨 int 타입을 얻고 long으로 승격시킨 다음 다시 래퍼 타입으로 감쌈.

 

자바에서는 래퍼 타입을 직접 다루기 위해 언박싱을 개발자가 스스로 해야 한다.

 

코틀린에서 타입 변환

val intVar: Int = 3
val longVar: Long = intVar // 컴파일 x
val longVar: Long = intVar.toLong() // 명시적 타입 변홥

 

코틀린에서는 기본 타입을 직접적으로 제공하지 않고, 래퍼 타입만 제공한다. 그래서 Int의 인스턴스를 Long 타입의 변수에 할당할 수 없고, toLong() 메소드와 같은 타입 변환 메소드를 사용해야 한다.참고로, 코틀린은 전부 래퍼 타입을 사용하지만, 바이트 코드 단에서 적절하게 기본 타입으로 최적화한다는 특징이 있다.

 

val intVar: Int = 3
val longVar: Long = intVar.toLong()
val longSum: Long = 3L + intVar // 3L + intVar.toLong() 할 필요 없음.

 

다행히 코틀린은 더하기 연산을 할 때는 자동으로 int 값을 long으로 변환하는 작업을 해 준다.

 

연산자 오버로딩

char charVal = '3';
int intVal = 3;

int sum = charVal + intVal;

 

Java는 피연산자 스택에 피연산자를 넣을 때 4바이트 단위로 넣으므로, 위 코드에서 charVal은 int로 승격이 된다.

 

val charVal: Char = '3'
val intVal: Int = 3

val sum: Char = charVal + intVal

 

하지만 코틀린은 형 변환 없이 그대로 Char형을 반환한다. 왜 그럴까?

 

public class Char private constructor() : Comparable<Char> {
    /**
     * Compares this value with the specified value for order.
     *
     * Returns zero if this value is equal to the specified other value, a negative number if it's less than other,
     * or a positive number if it's greater than other.
     */
    public override fun compareTo(other: Char): Int

    /** Adds the other Int value to this value resulting a Char. */
    public operator fun plus(other: Int): Char

    // ...
}

 

그것은 바로 코틀린은 연산자 오버로딩이 가능하기 때문이다. 더하기 연산에 대해 Char형을 반환하도록 정의해 둔 함수가 있다. 해당 개념은 추후 자세히 학습하도록 하자.

 

다른 기수로 출력하기

42를 이진법으로 출력하기

fun main(args: Array<String>) {
    println(42.toString(2))
}

// 실행 결과
101010

 

42를 사용 가능한 모든 기수로 출력

fun main(args: Array<String>) {
    (Character.MIN_RADIX..Character.MAX_RADIX).forEach { radix ->
        println("$radix: ${42.toString(radix)}")
    }
}

// 실행 결과
2: 101010
3: 1120
4: 222
5: 132
6: 110
7: 60
8: 52
9: 46
... 중략
29: 1d
30: 1c
31: 1b
32: 1a
33: 19
34: 18
35: 17
36: 16

 

숫자를 거듭 제곱하기

코틀린에는 기본 타입이 없어서 Int 같은 클래스 인스턴스가 자동으로 Long 또는 Double로 승격되지 않는다. 이러한 코틀린 동작 방식으로 인해 코틀린 표준 라이브러리의 Float와 Double에는 확장 함수 pow() 가 정의되어 있지만, Int나 Long에 상응하는 pow() 함수는 존재하지 않는다.

 

정수를 지수로 만들기

fun Double.pow(x: Double): Double
fun Float.pow(x: Float): Float

 

코틀린의 pow() 확장 함수는 위와 같다. 그래서 정수를 지수로 만들려면, Float 또는 Double로 변환한 후에 pow() 함수를 호출하고, 원래 타입으로 되돌려야 한다.

 

fun main(args: Array<String>) {
    println(2.toDouble().pow(8).toInt())
}

// 실행 결과
256

 

혹은 다음과 같이 내장 함수를 정의해 줄 수도 있다.

 

fun Int.pow(x: Int) = toDouble().pow(x).toInt()

fun main(args: Array<String>) {
    println(2.pow(8))
}

// 실행 결과
256

 

거듭 제곱 계산을 위한 중위 연산자 infix 정의하기

infix fun Int.`**`(x: Int) = toDouble().pow(x).toInt()

fun Int.pow(x: Int) = toDouble().pow(x).toInt()

fun main(args: Array<String>) {
    println(2.pow(8))
    println(2 `**` 8)
}

 

infix 키워드를 사용하면 일반 함수와 달리 .() 를 생략하여 함수를 직관적으로 표현할 수 있다.

 

비트 시프트 연산자 사용하기

  • shl: 부호 있는 왼쪽 시프트
  • shr: 부호 있는 오른쪽 시프트
  • ushr: 부호 없는 오른쪽 시프트

 

2를 곱하거나 나누기

fun main(args: Array<String>) {
    println(1 shl 1)
    println(1 shl 2)
    println(1 shl 3)
    println(1 shl 4)
    println(16 shr 1)
    println(16 shr 2)
    println(16 shr 3)
    println(16 shr 4)
}

// 실행 결과
2
4
8
16
8
4
2
1

 

shl 연산자를 사용하면 2를 곱하고, shr 연산자를 사용하면 2를 나누는 것을 알 수 있다.

 

ushr 함수 사용과 shr 함수 사용 비교

fun main(args: Array<String>) {
    val n1 = 5
    val n2 = -5

    println(n1 shr 1)
    println(n1 ushr 1)

    println(n2 shr 1)
    println(n2 ushr 1)
}

// 실행 결과
2
2
-3
2147483645

 

양수에 대해서 ushr, shr은 동작 과정이 동일하다. 하지만 음수에 대해 ushr을 사용하게 되면, 왼쪽을 0으로 채우기 때문에 -3의 음수 부호를 보존하지 않는다. 그 결과 32비트 정수인 -3의 2의 보수 값은 2_147_483_645가 나오게 된다.

 

큰 정수 2개의 중간 값 찾기

fun main(args: Array<String>) {
    val high = (0.99 * Int.MAX_VALUE).toInt()
    val low = (0.75 * Int.MAX_VALUE).toInt()

    val mid1 = (high + low) / 2
    val mid2 = (high + low) ushr 1

    println(mid1 in low..high) // false
    println(mid2 in low..high) // true
}

 

mid1은 두 큰 수의 합이 Int의 최댓값을 넘어가므로 결과는 음수가 나온다. 하지만, 부호 없는 시프트는 원하는 범위 내에서 결과 값을 얻는 것을 보장한다.

 

비트 불리언 연산자 사용하기

fun main(args: Array<String>) {
    val n1 = 0b0000_1100 // 십진수 12
    val n2 = 0b0001_1001 // 십진수 25

    println(n1 and n2) // 8
    println(n1 or n2) // 29
    println(n1 xor n2) // 21
    print(n1.inv()) // -13
}

 

to로 Pair 인스턴스 생성하기

public data class Pair<out A, out B>(
    public val first: A,
    public val second: B
) : Serializable {

    public override fun toString(): String = "($first, $second)"
}

public infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)

// ...

 

Pair는 first와 second라는 이름의 두 개의 원소를 갖는 데이터 클래스로, 생성자를 사용해서 Pair 인스턴스를 만들거나 위에서 정의한 중위 함수인 to() 를 사용할 수도 있다.

 

fun main(args: Array<String>) {
    val p1 = Pair("a", 1)
    val p2 = "a" to 1

    println(p1)
    println(p2)
}

// 실행 결과
(a, 1)
(a, 1)

 

또한, Pair를 사용해서 map을 생성할 수도 있다.

 

fun main(args: Array<String>) {
    val map = mapOf("a" to 1, "b" to 2, "c" to 5)
    println(map)
}

// 실행 결과
{a=1, b=2, c=5}

 


책의 모든 내용을 다루지는 않았습니다. 자세한 내용은 아래 도서를 구매하여 학습하시길 바랍니다.


출처

코틀린 쿡북 - 켄 코우젠 (저자), 김도남 (옮긴 이)

댓글

추천 글