새소식

Language/Kotlin

[Effective Kotlin] 1장 안전성 : Item 1번 ~ 5번

  • -

 

📌 [Item 1] 가변성을 제한하라

✅  var 보다는 val을 사용하여 가변성을 줄이자!

  • var
    • 읽고 쓰기가 모두 가능한 프로퍼티
  • val
    • 읽기 전용 프로퍼티지만, 변경할 수 없음을 의미하는 것은 아니다.
    • 완전 불변이 필요하다면 final 프로퍼티를 사용하는 것이 좋다.



✅  val & 스마트 캐스트

  • 타입 캐스트가 불가능한 경우
    val name: String? = "dong"
    val surname: String = "hyeok"
    
    val fullName: String?
        get( ) = name?.let{ "$it $surname"}
    
    fun main() {
    // 타입 캐스트가 불가능하기 때문에 fullName!!.length 와 같이 null이 아님을 강제해야 한다.
        if(fullName != null) {
            println(fullName!!.length)
        }
    }

 

  • 타입 캐스트가 가능한 경우
    val name: String? = "dong"
    val surname: String = "hyeok"
    
    val fullName: String? = name?.let{ "$it $surname"}
    
    fun main() {
        if(fullName != null) {
            println(fullName.length)// 타입 캐스트가 가능
        }
    }

 

 

✅  컬렉션

  • 읽기 전용
    • Iterable
    • Collection
    • Set
    • List
  • 읽고 쓰기 가능
    • MutableInterable
    • MutableCollection
    • MutableSet
    • MutableList

읽기 전용 컬렉션을 Mutable 컬렉션으로 다운캐스팅 하면 안된다!

만약 읽기 전용 컬렉션을 Mutable로 변경해야 한다면, 복제를 통해 새로운 Mutable 객체에 Copy를 하면된다.

이렇게 하면 기존 객체는 여전히 immutable하다.


✅  immutable 객체를 수정해서 사용해야 할 경우 새로운 객체를 만들어 사용한다.

  • Int는 immutable이다. Int 값을 수정할 수 있는 이유는 내부적으로 plus와 minus 메서드가 값을 수정한 새로운 객체를 생성하여 return하기 때문이다.
  • Iterable도 immutable이다. 때문에 map, filter 메서드로 새로운 객체를 만들어 리턴한다.
  • 객체의 속성값이 val인 경우 변경이 불가능하다. 위 방법처럼 값을 변경한 새로운 객체를 생성해야 한다.
        class Person(
                val age: Int,
                val name: String,
        ) {
            fun newName(name: String) = Person(age, name)
        }
  • data 한정자 사용
    data 한정자는 copy라는 이름의 메서드를 사용할 수 있다.
    copy메서드로 기본 생성자 프로퍼티가 같은 새로운 객체를 만들어 낼 수 있다.
        data class Person(
           val age: Int,
           val name: String,
        )
        
        private fun main() {
            var person = Person(27, "dong")
            println(person.hashCode())
            person = person.copy(age = 24)
            println(person.hashCode())
            println(person)
        }
        /**
         * 3090121
         * 3090028
         * Person(age=24, name=dong)
         */

 

 

 

✅  다른 종류의 변경 가능 지점

  • mutable 컬렉션 사용
    변경 가능 지점: 리스트 구현 내부 (멀티 스레드 환경에서 내부적 동기화 정확성 부재)
val list: MutableList<Int> = mutableListOf()

 

  • mutable 프로퍼티에 읽기 전용 컬렉션 사용
    변경 가능 지점: 프로퍼티 자체 변경시
val list: List<Int> = listOf()

 

  • 최악의 방법 (mutable 프로퍼티와 mutable 컬렉션을 함께 사용)
    이 방법은 프로퍼티와 컬렉션 두 곳 모두 동기화 문제를 관리해야 한다.
var list: MutableList<Int> = mutableListOf()

 

 

✅  변경 가능 지점 노출하지 말기

  • 방어적 복제(defensive copying)
    • 객체를 복제하여 리턴하자.
    • A class에 있는 private mutable객체를 리턴했을 때 리턴된 객체가 변경될 가능성이 있다. 👎
      이때 copy() 메서드로 객체 복제본을 만들어 리턴하는 것이 좋다. 👍



✅  정리

  • var보다는 val을 사용하는 것이 좋다.
  • mutable 프로퍼티보다 immutable 프로퍼티를 사용하는 것이 좋다.
  • mutable객체와 클래스보다는 immutable객체와 클래스를 사용하는 것이 좋다.
  • 변경이 필요한 대상을 만들어야 한다면, immutable 데이터 클래스로 만들고 copy를 활용하는 것이 좋다.
  • 컬렉션에 상태를 저장해야 한다면, mutable 컬렉션 보다는 읽기 전용 컬렉션을 사용하는 것이 좋다.
  • 변이 지점을 적절하게 설계하고, 불필요한 변이 지점은 만들지 않는 것이 좋다.
  • mutable객체를 외부에 노출하지 않는 것이 좋다.

 


📌 [Item 2] 변수의 스코프를 최소화하라

 

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

  • 프로퍼티는 지역 변수를 사용하는 것이 좋다.
  • 최대한 좁근 스포크를 갖게 변수를 사용하자.
  • 람다에서 변수를 캡처한다.

코틀린의 스코프는 기존적으로 중괄호로 만들어지며, 내부 스코프에서 외부 스코프로만 접근이 가능하다.

// 잘못된 최적화
val primes: Sequence<Int> = sequence {
    var numbers = generateSequence(2) { it + 1 }

    var prime: Int
    while (true) {
        prime = numbers.first()
        println(prime)
        yield(prime)
        numbers = numbers.drop(1).filter { it % prime != 0 }
    }
}

// 정상 
val primes2: Sequence<Int> = sequence {
    var numbers = generateSequence(2) { it + 1 }

    while (true) {
        val prime = numbers.first()
        println(prime)
        yield(prime)
        numbers = numbers.drop(1).filter { it % prime != 0 }
    }
}

fun main() {
    print(primes.take(10).toList()) // [2,3,5,6,7,8,9,10,11,12]
    print(primes2.take(10).toList()) // [2,3,5,7,11,13,17,19,23,29]
}

 

 

 


📌 [Item 3] 최대한 플랫폼 타입을 사용하지 말라

✅ 플랫폼 타입

  • 코틀린은 자바 등 다른 프로그래밍 언어에서 넘어온 타입들을 특수하게 다룬다.
  • 특수하게 다룬다? → 자바에서 온 타입을 플랫폼 타입으로 추론한다.
  • 플랫폼 타입은 null이 될 수도 있고 아닐 수도 있다.

 

✅ 플랫폼 타입을 지양 해야하는 이유

public class JavaClass {
    public String getString() {
        return null;
    }
}

 

  • StateType
    값을 가져오는 위치에서 NPE발생
fun stateType() {
    val javaString: String = JavaClass().value()
    println(javaString.length)
}

 

  • PlatformType
    값을 사용하는 위치에서 NPE발생
fun platformType() {
    val javaString = JavaClass().value() 
    println(javaString.length)
}

 

 

✅ 정리

위 예시를 살펴보면 플랫폼 타입은 타입 검사에서 문제가 검출되지 않습니다. 이는 추후 다른 사람이 해당 객체가 전파되는 상황(다른 곳에서 해당 객체를 사용하는 상황)에서 타입 에러인지 또는 어디서 무엇때문에 발생하는 에러인지 검출되기 더욱 어려운 상황을 초래합니다. 때문에 플랫폼 타입 사용은 지양하는 것이 좋습니다. 

 

 

 


📌 [Item 4] inferred 타입으로 리턴하지 마라

타입 추론이란 프로그래머가 변수나 함수 등의 타입을 명시적으로 선언하지 않아도, 컴파일러가 코드의 컨텍스트를 분석하여 자동으로 해당 식별자의 타입을 결정하는 기능이다.

 

✅ 코틀린 타입 추론

 

변수 선언 시 타입 추론

val number = 42 // Int로 추론
val message = "Hello, World!" // String으로 추론

 

 

 

함수 반환 타입 추론

이 함수에서는 반환 타입을 명시적으로 선언하지 않았습니다.
그러나 컴파일러는 함수의 본문인 n * n이 Int 타입을 반환한다는 것을 추론할 수 있습니다.
따라서 square 함수의 반환 타입은 Int로 추론됩니다.

fun square(n: Int) = n * n

 

 

제네릭 타입 추론
listOf 함수는 제네릭 함수로, 인자로 전달된 요소들의 타입을 기반으로 리스트의 타입 파라미터를 추론할 수 있습니다.
여기서는 모든 요소가 정수이므로, numbers는 List<Int> 타입으로 추론됩니다.

val numbers = listOf(1, 2, 3)

 

 

 

✅ 타입을 확실하게 지정해야 하는 경우

  • 타입추론은 코드의 명시성을 감소시킬 수 있습니다.
    복잡한 표현식, API 사용시 반환 타입이 명확하지 않은 경우 코드를 읽는 사람이 실제 타입을 직접 확인하기 위해 더 많은 시간과 노력을 들여야 합니다. 
  • 즉, 타입을 확실하게 지정해야 하는 경우에는 명시적으로 타입을 지정해야 합니다. 

 

 


📌 [Item 5] 예외를 활용해 코드에 제한을 걸어라 

확실한 형태로 동작해야 하는 코드가 있다면, 예외를 활용해 제한을 걸어두는 것이 좋다.

  • require 블록: 아규먼트(argument)를 제한할 수 있다.
  • check 블록: 상태와 관련된 동작을 제한할 수 있다.
  • assert 블록: 어떤 것이 true인지 확인할 수 있다. assert 블록은 테스트 모드에서만 작동한다.
  • return 또는 throw와 함께 활용하는 Elvis 연산자

 

✅ 아규먼트(argument)

함수를 정의할 때 타입 시스템을 활용해서 아규먼트(argument)에 제한을 거는 코드를 많이 사용합니다. 
일반적으로 함수의 가장 앞부분에 하게 되므로, 코드를 읽을 때 쉽게 확인할 수 있습니다.

 

require()

  • require 함수는 함수의 인자로 전달된 값이 특정 조건을 만족하는지 검사하는 데 사용됩니다.
    즉, 함수에 전달된 입력 값의 유효성을 검증할 때 주로 사용 됩니다.
fun setPercentage(percentage: Int) {
    try {
        require(percentage in 0..100) { "Percentage must be between 0 and 100" }
        // 유효성 검사를 통과하면, 여기서 로직을 계속 구현합니다.
        println("The provided percentage is $percentage")
    } catch (e: IllegalArgumentException) {
        // IllegalArgumentException이 발생했을 때의 처리 로직
        println("Error: ${e.message}")
    }
}

fun main() {
    setPercentage(101)  // 유효하지 않은 값으로 함수를 호출합니다.
    setPercentage(50)   // 유효한 값으로 함수를 호출합니다.
}

percentage가 0과 100 사이에 있는지 검사합니다. 만약 percentage가 이 범위를 벗어나면, require 함수는IllegalArgumentException을 발생시키고, "Percentage must be between 0 and 100"라는 메시지를 예외에 포함시킵니다.

 

 

✅ 상태

어떤 구체적인 조건을 만족할 때만 함수를 사용할 수 있게 해야 할 때가 있습니다.

  • 어떤 객체가 미리 초기화되어 있어야만 처리를 하게 하고 싶은 함수
  • 사용자가 로그인했을 때만 처리를 하게 하고 싶은 함수
  • 객체를 사용할 수 있는 시점에 사용하고 싶은 함수

check()

  • check 함수는 프로그램의 상태나 객체의 상태가 특정 조건을 만족하는지 검사하는 데 사용된다.
// 사용자의 로그인 상태를 반환하는 함수 (임의로 구현)
fun isUserLoggedIn(): Boolean {
    return false
}

fun performAction() {
    check(isUserLoggedIn()) { "User must be logged in to perform this action" }
    
    println("Performing action for the user")
}

fun main() {
    try {
        performAction() // 이 함수를 호출하면, isUserLoggedIn이 false를 반환하기 때문에 IllegalStateException이 발생합니다.
    } catch (e: IllegalStateException) {
        println("Error: ${e.message}") // 에러 메시지를 출력합니다.
    }
}

check 함수는 require와 비슷하지만, 지정된 예측을 만족하지 못할 때, IllegalStateException를 throw합니다.

함수 전체에 대한 어떤 예측이 있을 때는 일반적으로 require 블록 뒤에 check 블록을 배치한다. 즉, check를 나중에 한다.

 

 

✅ Assert 계열 함수 사용

Kotlin과 같은 프로그래밍 언어에서 assert 계열의 함수는 개발 과정에서 특정 조건이 참인지 검증하는 데 사용됩니다.
assert 함수를 사용하는 주된 목적은 개발 단계에서 버그를 조기에 발견하고 수정하는 것입니다.

  • Assert 계열의 함수는 코드를 자체 점검하며, 더 효율적으로 테스트할 수 있게 해준다.
  • 특정 상황이 아닌 모든 상황에 대한 테스트를 할 수 있다.
  • 실행 시점에 정확하게 어떻게 되는지 확인할 수 있다.
  • 실제 코드가 더 빠른 시점에 실패하게 만든다. 따라서 예상하지 못한 동작이 언제 어디서 실행되었는지 쉽게 찾을 수 있다.
  • assert 함수는 기본적으로 JVM의 -ea (enable assertions) 옵션이 활성화되어 있을 때만 실행된다. (해당 옵션이 없으면 assert는 무시된다.)

assert 함수는 조건을 하나의 인자로 받으며, 이 조건이 false일 경우 AssertionError를 발생시킵니다

fun main() {
    val positiveNumber = -10

    // assert 함수를 사용하여 조건을 검사하고, 조건이 false일 경우 AssertionError를 발생시킵니다.
    assert(positiveNumber > 0) { "제공된 숫자는 양수여야 합니다. 실제 값: $positiveNumber" }

    println("프로그램이 성공적으로 실행되었습니다.")
}

표준 애플리케이션 실행에서는 assertrk 예외를 throw하지 않는다! assert는 주로 개발 단계에서 오류를 찾는 데 사용되며, 프로덕션 코드에서는 사용을 지양해야 한다. 만약 해당 코드가 정말 심각한 오류라면 check를 사용하는 것이 좋다.

 

 

✅ nullability와 스마트 캐스팅

require와 check 블록으로 어떤 조건을 확인해서 true가 나왔다면, 해당 조건은 이후로도 true일 거라고 가정하는 것을 스마트 캐스팅이라고 합니다. require와 check 둘 다 스마트 캐스트를 지원하므로, 변수를 언팩하는 용도로 활용할 수 있습니다.

fun changeDress(person: Person) {
    require(person.outfit is Dress)
    val dress: Dress = person.outfit
}

fun sendEmail(person: Person, message: String) {
    require(person.email != null)
    val email: String = person.email
}

 

  • checkNotNull, requireNotNull
fun sendEmail(person: Person, message: String) {
    val email = requireNotNull(person.email)
    println(email)
}

requireNotNull 함수는 인자로 받은 person.emailnull이 아닌지 확인합니다. null이면 IllegalArgumentException을 던지고, null이 아니라면 person.email의 값을 email 변수에 할당합니다. email 프로퍼티가 null이 아님을 보장받습니다. 

 

  • nullability를 목적으로 Elvis연산자와 throw 또는 return을 사용하기
fun getName(person: Person?): String {
    // person의 name 프로퍼티가 null일 경우 "Unknown"을 반환합니다.
    return person?.name ?: return "Unknown"
}

fun getAge(person: Person?): Int {
    // person의 age 프로퍼티가 null일 경우 IllegalArgumentException 예외를 던집니다.
    return person?.age ?: throw IllegalArgumentException("Age cannot be null")
}

 

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.