아이템 2 - 변수의 스코프를 최소화하라

ds_chanin

·

2022. 2. 26. 17:15


가시성 제한

상태를 정의할 때는 변수와 프로퍼티의 스코프를 최소화하는 것이 좋다.

  • 프로프티보다 지역 변수를 사용하는 것이 좋다.
  • 최대한 스코프를 좁게 가지도록 변수를 사용하자.
    • 사용되는 곳에서만 사용될 수 있도록
      • ex) 반복문 내에서만 존재하는 경우
// AS-IS
var user: User
for (index in users.indices) {
    user = users[index]
    print("User at $index is $user")
}

// TO-BE
for (index in users.indices) {
    val user = users[index]
    print("User at $index is $user")
}

// BEST
for ((index, user) in users.indices) {
    print("User at $index is $user")
}

스코프를 좁게 제한하고 사용할수록 당연히 추적하기 쉬워지고 디버그가 편해진다. 또한 다른 개발자가 잘못 사용하는 불상사를 방지할수 있다. 결국 관리가 편해진다.

가능한 변수 선언시 초기화 하도록 하자.

자바에서 try-catch, if-else 를 이용하는 경우 어쩔수 없이 변수 초기화를 나중에하는 경우가 있는데 코틀린은 이를 피할 수 있다.

// Java
public int getColor(String colorName) {
    int color;	
    if ("RED".equals(colorName)) {
        color = 1; 
    } else if ("BLUE".equals(colorName)) {
        color = 2;
    } else {
        color = 3;
    }

    return color; 
}

// Kotlin
fun getColor(colorName: String): Int {
    val colorNumber = when (colorName) {
        "RED" -> 1
        "BLUE" -> 2
        else -> 3
    }
    return colorNumber
}

복수개의 프로퍼티라면 구조분해 선언으로 해결할 수 있다.

fun printWeather(degrees: Int) {
    val (description, color) = when {
        degrees < 5 -> "cold" to 2
        degrees < 23 -> "mild" to 3
        else -> "hot" to 1
    }

    println("weather is $description($degrees)")
}

캡처링

코루틴의 SequenceBuilder(시퀀스빌더)에서 스코프를 최소화 하지 않으면서 발생할 수 있는 문제를 살펴본다.

에라토스테네스의 체(소수 구하기)를 구현한다.

  1. 2부터 시작하는 리스트를 만든다.
  2. 리스트의 첫번째 수는 소수로 저장하고 리스트에서 제외시킨다.
  3. 2에서 제외시킨 소수로 남은 리스트에서 나누어 0이 되는 숫자를 전부 제외한다.
  4. 원하는 개수의 소수를 구할때까지 1로 돌아가 과정을 반복한다.

자바를 먼저 경험했다면 책에 나온 예시처럼 아래와 작성하는것이 익숙할지 모른다.

fun runLikeJava() {
    var numbers = (2..100).toList()
    val primes = mutableListOf<Int>()
    while (numbers.isNotEmpty()) {
        val prime = numbers.first()
        primes.add(prime)
        numbers = numbers.filter { it % prime != 0 }
    }
    println(primes)
}
//[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97]

이를 시퀀스 빌더로 리팩토링하면 다음과 같이 변경된다.

primes.toList()가 불리는 실행되는 순간 squence 블록 내부가 실행된다. 그리고 yield가 실행되면 sequence 블록 내의 행동이 정지(suspend)되고 다시 toList()가 위치한 main으로 실행주도권이 넘어간다. 그리고 다시 toList()가 실행되며 suspend되었던 sequence 블록 내부가 실행되는데 이때 이전에 yield 하며 멈춰있던 부분부터 다시 실행된다.

fun runWithSequenceBuilder() {
    val primes: Sequence<Int> = sequence {
        var numbers = generateSequence(2) { it + 1 }
        while (true) {
            val prime = numbers.first()
            yield(prime) // sequence에 prime을 넘겨준다.
            numbers = numbers.drop(1)
                .filter { it % prime != 0 }
        }
    }
    println(primes.take(25).toList()) // sequence에 쌓인것중 25개를 List로 변환한다.
}
//[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97]

그런데 만약 여기서 prime의 선언 위치를 while문 바깥에서 했다면 결과가 이상해진다.

fun runWithSequenceBuilderButWrongScope() {
    val primes: Sequence<Int> = sequence {
        var numbers = generateSequence(2) { it + 1 }
        var prime: Int
        while (true) {
            prime = numbers.first()
            yield(prime)
            numbers = numbers.drop(1)
                .filter { it % prime != 0 }
        }
    }
    println(primes.take(25).toList())
}
//[2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27]

시퀀스는 지연연산을 한다. 종단연산이 불리기 전까지 연산을 하지 않고 시퀀스의 정의대로 나열되어 있는데 이 상태가 무한할 수 있다. 위 함수에서 numbers가 2로 시작해서 1씩 더 해지는 일련의 숫자의 나열인 것 처럼 말이다.

위 구조를 지연연산이 아니라고 생각한다면 아마 아래와 같이 흐른다고 생각하지 않았을까 싶다.

image

하지만 시퀀스는 위와같이 filter, drop과 같은 중간 연산에서는 바로 연산을 수행하지 않고 종단 연산인 first를 만날때까지 연산을 누적해서 기다린다.

image

위와 같이 즉시 연산을 하지 않고 누적한 뒤 종단연산이 불릴때 누적되었던 모든 연산을 다시 순차적으로 실행하기 때문에 변수의 선언위치에 따라 filter의 prime 값이 전부 하나로 동일한 상황이 발생하여 생각한대로 동작하지 않는다.

디버그를 통해 확인해보면 아래와 같이 나온다.

GeneratorSequence(2부터 1씩증가하는 시퀀스) 위로 2개의 FilteringSequence와 2개의 DropSequence가 교차해서 쌓여있는것을 확인할 수 있다.

정리

책에서 나오는 것 처럼 여러가지 이유로 변수의 스코프를 최소한으로 제한하여 사용하는 것이 좋음을 알 수 있다. 특히 람다의 경우 변수를 캡쳐하기 때문에 더욱 조심해야한다.