본문 바로가기
공부/이펙티브코틀린

아이템 8 - 적절하게 null 을 처리하라

by 띵커베르 2024. 4. 4.
728x90
  • null 을 리턴한다는 것은 함수에 따라서 여러 의미를 가질 수 있다
@Test
fun `52_1`() {
    var name: String = "M"
    // String 을 Int 로 변환할 수 없을 경우 null 을 반환
    val toIntOrNull = name.toIntOrNull()
    println("toIntOrNull: $toIntOrNull")

    val list = listOf(1, 2, 3)
    // 조건에 맞는 첫 번째 요소를 반환하거나 없으면 null 을 반환
    val firstOrNull = list.firstOrNull { it % 4 == 0 }
    println("firstOrNull: $firstOrNull")

    // toIntOrNull: null
    // firstOrNull: null
}

 

  • 기본적으로 nullable 티입은 세가지 방법으로 처리합니다.
    • ?., 스마트 캐스팅, Elvis 연산자 등을 활용해서 안전하게 처리한다
    • 오류를 throw 한다
    • 함수 또는 프로퍼티를 리팩터링해서 nullable 타입이 나오지 않게 바꾼다

 

  • null 을 안전하게 처리하기
    • null 을 안전하게 처리하는 방법 중 널리 사용되는 방법으로는 안전호출(safe call) 과 스마팅 캐스팅이 있다
@Test
fun `53_1`() {
    val name: String? = "injin Jeong"
    name?.println()

    val nameIsNull: String? = null
    nameIsNull?.println()

    if (nameIsNull != null) {
        nameIsNull.println()
    }

    nameIsNull?.println() ?: "nameIsNull is null".println()

    // injin Jeong
    // nameIsNull is null
}

fun String.println() {
    println(this)
}

 

  • 많은 객체가 nullable 과 관련된 처리를 지원합니다
  • 예를 들어 컬렉션 처리를 할 때 무언가 없다는 것을 나타낼 때는 null 이 아닌 빈 컬렉션을 사용하는 것이 일반적이다
  • 엘비스 연산자는 오른쪽에 return 또는 throw 을 포함한 모든 표현식이 허용됩니다.
    • return 과 throw 모두 Nothing(모든 타입의 서브타입)을 리턴하게 설계되어서 가능하다
      • Nothing 타입: kotlin 에서 특별한 경우를 나타내기 위해 사용되는 타입. 프로그램의 흐름이 정상적인 경로를 벗어난 지점을 나타내기 위해 설계됨.
        함수의 반환 타입으로 사용될 경우 해당 함수가 정상적으로 값을 반환하지 않고, 항상 예외를 던지거나 프로그램을 종료한다는 것을 의미.
        ex: 확장함수로 즐겨쓰는 findByIdOrThrow(직접만든거라 라이브러리 메서드가 아님..) 같은 곳을 보면 Nothing 반환으로 마무리됨.
  • 따라서 Collection<T>?.orEmpty() 확장 함수를 사용하면 nullable 이 아닌 List<T> 를 리턴받습니다
  • 스마트 캐스팅은 코틀린의 규약 기능(contracts feature)을 지원합니다.이 기능을 사용하면 다음 코드처럼 스마트 캐스팅 할 수 있다.
@Test
fun `53_2`() {
    val list: List<Int>? = null

    val notNullList: List<Int> = list.orEmpty()

    println("list: $list") // list: null
    println("notNullList: $notNullList") // notNullList: []
}

 

  • 방어적 프로그래밍과 공격적 프로그래밍
    • 방어적 프로그래밍: 모든 가능성을 올바른 방식으로 처리하는 것(예를 들어 null 일 때는 출력하지 않기 등)을 방어적 프로그래밍 이라고 부른다.
      • 방어적 프로그래밍은 코드가 프로덕션 환경으로 들어갔을 때 발생할 수 있는 수많은 것들로부터 프로그램을 방어해서 안정성을 높이는 방법을 나타내는 굉장히 포괄적인 용어
      • 상황을 처리할 수 있는 올바른 방법이 있을 때는 굉장히 좋습니다
    • 공격적 프로그래밍
      • 모든 상황을 안전하게 처리하는 것은 불가능합니다 이러한 경우에는 공격적 프로그래밍 이라는 방법을 사용합니다.
      • 예상치 못한 상황이 발생했을 때, 이러한 문제를 개발자에게 알려서 수정하게 만드는 것
      • 아이템 5: 예외로 코드에서 제한 걸기(https://jeong0427.tistory.com/202) 에서 살펴보았던 require, check 등이 바로 이러한 공격적 프로그래밍을 위한 도구이다.
    • 이름 때문에 둘이 충돌되는 것처럼 보이지만, 둘은 안전을 위해 모두 필요합니다.
    • 둘을 모두 이해하고 적절하게 사용할 수 있어야 한다.

  • 오류 throw 하기
    • 예상하지 못한 오류나 처리가 발생했다면 개발자가 오류를 찾기 어렵게 만든다
    • 이런 문제가 발생할 경우에는 오류를 강제로 발생시켜 주는 것이 좋다
    • 이럴때는 throw, !!, requireNotNull, checkNotNull 등을 활용 하자
@Test
fun `55_1`() {
    val name: String? = null
    assertThrows<IllegalArgumentException> {
        name ?: throw IllegalArgumentException("name is null")
    }

    assertThrows<NullPointerException> {
        name!!.println()
    }

    assertThrows<IllegalArgumentException> {
        requireNotNull(name) { "name is null" }
    }
}

 


 

  • not-null assertion(!!) 과 관련된 문제
    • nullable 을 처리하는 가장 간단한 방법은 not-null assertion(!!)을 사용하는 것입니다.
    • !! 를 사용하면 자바에서 nullalbe 을 처리할 때 발생할 수 있는 문제가 똑같이 발생한다 (NPE)
    • !! 은 사용하기 ㅜ쉽지만 좋은 해결 방법은 아니다.
    • !! 은 nullable 이지만, null 이 나오지 않는다는 것이 거의 확실한 상황에서 많이 사용됩니다 하지만 현재 확실하다고, 미래에 확실한 것은 아닙니다. 문제는 미래의 어느 순간에 일어납니다.
    • 참고 책에서 max() 1.4 버전 이전에서 사용가능 -> 현재는 maxOrNull() 사용

 

  • 의미 없는 nullability 피하기
    • nullability 는 어떻게든 적절하게 처리해야 하므로, 추가 비용이 발생한다. 따라서 필요한 경우가 아니라면 nullability 자체를 피하는 것이 좋다
    • null 은 중요한 메시지를 전달하는 데 사용될 수 있다 따라서 다른 개발자가 보기에 의미가 없을 때는 null 을 사용하지 않는 것이 좋다. 만약 이유 없이 null 을 사용했다면, 다른 개발자들이 코드를 작성할 때, 위험한 !! 연산자를 사용하게 되고, 의미 없는 코드를 더럽히는 예외 처리를 해야 할 것입니다.
    • nullability 를 피하는 몇가지 방법
      • 클래스에서 nullability 따라 여러 함수를 만들어 제공할 수 있다 대표적으로 List<T> 의 get 과 getOrNull 함수가 있다
        아이템 7 - 결과 부족이 발생할 경우 null 과 Failure 를 사용하라(https://jeong0427.tistory.com/204)참조
      • 어떤 값이 클래스 생성 이후에 확실하게 설정된다는 보장이 있다면 lateinit 프로퍼티와 notNull 델리게이트를 사용하세요.
class MyClass {
    lateinit var myProperty: String

    // 초기화 여부 확인
    fun isInitialized(): Boolean = ::myProperty.isInitialized
    fun initializeProperty() {
        myProperty = "Hello, World!"
    }
}

@Test
fun `58_lateinit_test`() {
    val myClass = MyClass()
    println("isInitialized: ${myClass.isInitialized()}") // false
    myClass.initializeProperty() // 프로퍼티 초기화
    println("isInitialized: ${myClass.isInitialized()}") // true
    println(myClass.myProperty) // 이제 안전하게 접근 가능
}

class MyClass2 {
    var myProperty: String by Delegates.notNull<String>()

    fun initializeProperty() {
        myProperty = "Hello, Kotlin!"
    }
}

@Test
fun `58_Delegates_test`() {
    val myClass = MyClass2()
    myClass.initializeProperty() // 프로퍼티 초기화
    println(myClass.myProperty) // 이제 안전하게 접근 가능
}
  • nullability 를 피하는 몇가지 방법 이어서..
    • 빈 컬렉션 대신 null 을 리턴하지 마세요. List<Int>? 와 Set<String?> 과 같은 컬렉션을 빈 컬렉션으로 둘 때와 null 로 둘 때는 의미가 완전히 다릅니다 -> List? 와 Set<string?> 과 같은 컬렉션</string?> 을 웬만하면 사용하지 말자..
    • nullable enum 과 None enum 값은 완전히 다른 의미이다.
      • null enum 은 별도로 처리해야 하지만 None enum 정의에 없으므로 필요한 경우에 사용하는 쪽에서 추가해서 활용할 수 있다는 의미이다.
      • enum 클래스에 NONE 이라는 프로퍼티를 두고 사용하자라는 말인거 같다.
enum class ProcessStatus {
    STARTED,
    IN_PROGRESS,
    COMPLETED,
    FAILED,
    NONE // 어떤 프로세스 상태도 아닌, 초기 상태나 유효하지 않은 상태를 나타내는 멤버
}

fun reportStatus(status: ProcessStatus) {
    when (status) {
        ProcessStatus.NONE -> println("프로세스가 시작되지 않았거나 상태가 유효하지 않습니다.")
        ProcessStatus.STARTED -> println("프로세스가 시작되었습니다.")
        ProcessStatus.IN_PROGRESS -> println("프로세스가 진행 중입니다.")
        ProcessStatus.COMPLETED -> println("프로세스가 완료되었습니다.")
        ProcessStatus.FAILED -> println("프로세스가 실패했습니다.")
    }
}

@Test
fun `58_Enum`() {
    val currentStatus = ProcessStatus.NONE
    reportStatus(currentStatus) // 프로세스가 시작되지 않았거나 상태가 유효하지 않습니다.
}

 

 


 

  • lateinit 프로퍼티와 notNull 델리게이트
    • 클래스가 클래스 생성 중에 초기화할 수 없는 프로퍼티를 가지는 것은 드문 일은 아니지만 분명 존재하는 일이다.
    • 이러한 프로퍼티는 사용 전에 반드시 초기화해서 사용해야 한다. 예) @BeforeEach
    • lateinit 한정자는 프로퍼티가 이후에 설정될 것임을 명시하는 한정자입니다.
    • lateinit 를 사용할 경우에도 비용이 발생한다. 만약 초기화 전에 값을 사용하려고 하면 예외가 발생한다.
    • 처음 사용하기 전에 반드시 초기화가 되어 있을 경우에만 lateinit 을 붙이는 것
    • lateinit 는 nullable 과 비교해서 다음과 같은 차이가 있다
      • !! 연산자로 언팩 하지 않아도 된다
        • 초기화 되기전에 접근시 UninitializedPropertyAccessException
      • 이후에 어떤 의미를 나타내기 위해서 null 을 사용하고 싶을때, nullable 로 만들 수도 있다
        • 말이 좀 이상한거 같은데 lateinit 은 초기화를 진행하면 다시는 null 로 돌릴수가 없는데..
        • lateinti 을 이용할지, var 를 이용해 nullable 를 이용할지 선택하라는건가...
        • 잘 이해가 안되네..넘어가자
      • 프로퍼티가 초기화된 이후에는 초기화되지 않은 상태로 돌아갈 수 없습니다.
        • 한 번 초기화된 이후에는 다시 초기화되지 않은 상태, 즉 null로 돌아갈 수 없습니다.
      • lateinit 은 프로퍼티를 처음 사용하기 전에 반드시 초기화 될 거라고 예상되는 상황에 활용합니다.
      • 반대로 lateinit 을 사용하지 못하는 경우도 있다.JVM 에서 Int, Long, Double, Boolean 과 같은 기본 타입과 연결된 타입으로 초기화 해야하는 경우입니다. 이런 경우에는 lateinit 보다는 약간 느리지만 Delegates.notNull 을 사용합니다.
        • 둘 다 non-null 타입으로 지연 초기화를 가능하게 한다는 점은 같다. 하지만 non-null 타입의 var 프로퍼티에만 사용가능 한번 초기화된 후에는 non-null 상태를 유지 -> null 로 재할당 할 수 없다
          Delegates.notNull 는 primitive 타입을 포함한 모든 non-null 타입의 var 프로퍼티에 대한 지연 초기화를 제공함.
        • JVM 은 기본타입은 스택 메모리에 저장하고, 이는 non-null 이 보장되고, lateinit 은 참조타입에 대해서만 사용할 수 있다.
        • 참고: String 은 좀 다름 ㅎ -> 참조타입이고 좀 특별 함. -> 참조인데 불변타입임ㅎㅎ
          • String str1 = "Hello"; 과 String str3 = new String("Hello"); 가 다름 ㅎ
          • constant poll, heap memory

 


 

정리

null 을 적절히 처리하는것은 항상 중요한 문제였으며, 코틀린은 nullable 처리를 참 쉽게 잘 만들어 논거 같다

enum class 에서 NONE 은 나중에 한번 사용해 보도록하던지..하고..

lateinit 과 deleates 에 좀 더 알게되는 아이템이였다.

String 의 Constant Pool 에 대해 오랜만에 생각이 나서 좋았다.

728x90

댓글