범위 지정 함수

맥모닝·2023년 11월 27일
0

Kotlin

목록 보기
7/10

범위 지정 함수(Scope function)

코틀린 표준 라이브러리에서 제공하는 확장함수로, 객체의 컨텍스트 내에서 실행 가능한 코드 블록을 만들어 준다. 호출 시 임시 범위가 생성되며, 이 범위 안에서는 이름 없이 객체에 접근이 가능하다.

수신 객체 지정 람다

수신 객체를 명시하지 않고, 람다의 본문 안에서 해당 객체의 메서드를 호출할 수 있게 하는 람다 표현식

수신 객체(receiver) : 확장 함수가 호출되는 대상이 되는 값(객체)

수신 객체 타입(receiver type) : 해당 객체의 클래스 이름

inline fun <T, R> with(receiver: T, block: T.() -> R): R {
    return receiver.block()
}
  • 수신 객체 : receiver 매개변수로 전달된 T 타입의 객체
  • 수신 객체 타입 : T
  • 수신 객체 지정 람다 : block
  • T 타입의 수신 객체를 받아서 해당 객체의 확장 함수인 block: T.() -> R를 실행하고 그 결과인 R을 반환
코틀린 범위 지정 함수
수신 객체 확장 함수로 호출 함수의 인자
this (생략 가능) apply run with
it (생략 불가능) also let
return 수신 객체 람다 식의 마지막 행

let 함수

수신 객체를 it로 받아서 람다 내부에서 사용하며, 람다의 마지막 표현식을 결과로 반환한다.

null이 아닌 객체에 대한 작업을 수행하거나, 지역 변수를 명시적으로 표현할 때 주로 사용한다.

class Person(val name: String, val age: Int, val gender: Boolean)

fun main() {
    var person: Person? = Person("영희", 10, true)
    // 단일 지역 변수(nonNullPerson)의 범위를 제한하는 경우
    val age = person?.let { nonNullPerson ->
        nonNullPerson.age
    }
    println(age) // 10
}
  • Nullable 객체를 다른 Nullable 객체로 변환하는 경우
val driversLicence: Licence? = getNullablePerson()?.let {
    licenceService.getDriversLicence(it)
}

Immutable 변수의 null 체크

// 추천하지 않는 코드
fun process(str: String?) {
	str?.let { ... }
}

// 자바로 디컴파일된 코드
public final void process(@Nullable String str) {
   if (str != null) {
      boolean var4 = false;
      ...
   }
}

// 추천하는 코드
fun process(str: String?) {
	if (str != null) { ... }
}

// 자바로 디컴파일된 코드
public final void process(@Nullable String str) {
   if (str != null) { ... }
}
  • Immutable한 변수를 let을 사용해서 null 체크를 하는 경우 자바로 디컴파일된 코드를 확인해보면 쓸데없는 변수가 추가된다.
  • 쓸모없는 변수가 늘어나는 것을 방지하기 위해 if문을 사용해서 null을 체크하는 것이 좋다.

Mutable 변수의 null 체크

  • Mutable 변수의 null을 체크하는 경우 let을 사용하면 Scope 내부에서 Immutable을 보장한다.
private var str: String? = null

fun process() {
	str?.let { ... }
}
  • Scope 내부에서 외부 Scope의 값을 적용하려고 할 때는 let을 사용하여 명시적으로 this와 구분할 수 있다.
// 추천하지 않는 코드
var javaScriptEnabled = false
var databaseEnabled = false

webviewSetting?.run {
    javaScriptEnabled = javaScriptEnabled
    databaseEnabled = databaseEnabled
}

// let을 사용하여 수정한 코드
var javaScriptEnabled = false
var databaseEnalbed = false

webviewSetting?.let {
    javaScripeEnabled = it.javaScriptEnabled
    databaseEnabled = it.databaseEnabled
} 

긴 nullable chain을 사용할 때

// 추천하지 않는 코드
fun process(string: String?): List? {
	return string?.asIterable()?.distinct()?.sorted()
}

// let을 사용하여 수정한 코드
fun process(string: String?): List? {
	return string?.let {
    	it.asIterable().distinct().sorted()
    }
}
  • 만약 nullable chain을 계속해서 사용하게 되면 자바로 decompile할 경우 ?가 나올 때마다 if문을 통해서 체크하게 된다.
  • 하지만 let을 안전 호출 연산자(?.)와 함께 쓰는 경우 if문을 한 번만 사용하는 코드로 변경할 수 있다.

Scope의 마지막 값을 사용하고자 할 때

// 추천하지 않는 코드
fun process(stringList: List<String>?, removeString: String): Int? {
    var count: Int? = null
    
    if (stringList != null) {
        count = stringList.filterNot { it == removeString }
            .sumOf { it.length }
    }
    
    return count
}

// let을 사용하여 수정한 코드
fun process(stringList: List<String>?, removeString: String): Int? {
    return stringList?.let { list ->
        list.filterNot { it == removeString }
            .sumOf { it.length }
    }
}
  • let을 사용해서 if문에서 사용한 불필요한 count 변수 추가를 막을 수 있다.

run 함수

수신 객체를 this로 받아서 람다 내부에서 사용하며, 람다의 마지막 표현식을 결과로 반환한다.

객체의 초기화와 반환값을 동시에 처리할 때 유용하다.

class User(val name: String, val age: Int, val gender: Boolean)

fun main() {
    val kid = User("아이", 4, false)
    val kidAge = kid.run {
        age
    }
    println(kidAge) // 4
}
  • 수신객체 없이 run을 사용하면 내부에 수신객체를 명시해줘야 한다.
// 수신 객체 없이도 동작할 수 있다.
fun main() {
    val kid = User("아이", 4, false)
    val kidAge = run {
        kid.age
    }
    println(kidAge) // 4
}

// 여러 개의 지역 변수의 범위를 제한할 수 있다.
val inserted: Boolean = run {
    // person 과 personDao 의 범위를 제한한다.
    val person: Person = getPerson()
    val personDao: PersonDao = getPersonDao()
    // 수행 결과를 반환한다.
    personDao.insert(person)
}

apply 함수

수신 객체를 this로 받아서 람다 내부에서 사용하며, 수신 객체 자체를 반환한다.

객체의 초기화와 그 객체를 반환하려 할 때 주로 사용한다.

class User(
    val name: String,
    val age: Int,
    val gender: Boolean,
    var hasGalsses: Boolean = true
)

fun main() {
    val kid = User("아이", 4, false)
    val kidName = kid.apply {
        name
    }
    println(kidName) // 수신객체 자기 자신(kid) 반환
  
    val female = User("슬기", 20, true, true)
    val femaleValue = female.apply {
        hasGalsses = false
    }
    print(femaleValue.hasGalsses) // false
}

also 함수

수신 객체를 it로 받아서 람다 내부에서 사용하며, 수신 객체 자체를 반환한다.

객체의 초기화 이후 추가적인 작업을 수행하면서 그 객체를 계속 사용하려 할 때(로깅 등) 유용하다.

data class User(
    val name: String, 
    val age: Int, 
    val gender: Boolean, 
    var hasGalsses: Boolean = true
)

fun main() {
    val male = User("민수", 17, false, true)
    val maleValue = male.also {
        println(it.name) 
    }.toString()
    println(maleValue)
}

// 민수
// User(name=민수, age=17, gender=false, hasGalsses=true)
  • 객체의 부수효과를 확인하거나 수신 객체의 프로퍼티에 데이터를 할당하기 전에 해당 데이터의 유효성을 검사할 때 매우 유용하다.
class Book(val author: Person) {
    init {
        requireNotNull(author.age)
        print(author.name)
    }
}

class Book(author: Person) {
    val author = author.also {
        requireNotNull(it.age)
        print(it.name)
    }
}

with 함수

run 함수와 비슷하지만, 함수를 호출하는 대상이 아닌 첫 번째 인자로 받는 객체를 this로 받아서 람다 내부에서 사용하며, 람다의 마지막 표현식을 결과로 반환한다.

객체의 초기화와 람다 리턴 값이 필요 없을 때 주로 사용한다.

class User(
    val name: String,
    val age: Int,
    val gender: Boolean,
    var hasGalsses: Boolean = true,
)

fun main() {
    val male = User("민수", 17, false, true)
    val result = with(male) {
        hasGalsses = false
        true
    }
    print(result) // true
}

this vs it

  • this : 수신 객체를 가리키는 키워드로, 객체의 멤버에 직접 접근할 수 있으며, 객체의 속성을 초기화하거나 설정하는 등의 작업을 쉽게 수행할 수 있다.

  • it : 람다에서 단일 인자를 가리키는 키워드로, 수신 객체의 프로퍼티나 함수에 직접 접근하지 않고, 해당 객체 자체에 대한 참조를 사용한다.

Q1. 직접 참조와 간접 참조의 차이점 : 객체의 멤버에 직접 접근하면 코드에서 명시적으로 수신 객체를 참조하므로, 해당 객체에 대한 작업이 명확하게 드러난다. 간접적으로 수신 객체의 멤버에 접근하면 코드가 보다 간결해진다. 특히, 람다 또는 함수에서 수신 객체의 멤버를 적은 횟수로 사용하는 경우에 더 적합하다.

Q2. 수신 객체 자체를 반환한다? https://kotlinworld.com/255

takeIf, takeUnless

  • takeIf : Predicate를 받아서 만약 결과가 true라면 해당 객체를 리턴해주고, false라면 null을 리턴한다.
public inline fun <T> T.takeIf(predicate: (T) -> Boolean): T? 
    = if (predicate(this)) this else null

return if(x.isValid()) x else null  // 전
return x.takeIf { it.isValid() }    // 후
  • takeUnless : 반대로 Predicate를 받아서 결과가 false라면 해당 객체를 리턴해주고, true라면 null을 리턴한다.
public inline fun <T> T.takeUnless(predicate: (T) -> Boolean): T? 
    = if (!predicate(this)) this else null

return if(!x.isError()) x else null  // 전
return x.takeUnless { it.isError }   // 후
  • nullable 타입을 리턴하기 때문에 반드시 안전한 호출 연산자(?.)를 함께 사용해야 컴파일이 가능하다.
  • nullable한 값이 올 수 있기 때문에 let과 안전한 호출 연산자(?.)를 함께 사용하는 것이 좋다.

추천하지 않는 경우

// 수정 전
return if(x.isValid()) doWorkWith(x) else null
// 수정 후
return doWorkWith(x).takeIf { x.isValid() }

1. 연산의 순서

  • Error 발생 : doWorkWith(x) 함수가 predicate 평가 시점 이전에 호출되기 때문이다. x가 유효한지 유효하지 않은지를 판단하기도 전에 doWorkWith(x)가 호출된다.

2. 초과 연산

  • if/else 절에는 x가 유효하지 않을 경우 else절에 가지 않기 때문에 따로 연산을 하지 않는다.
  • 반면 takeIf()를 사용할 경우 코드는 항상 doWorkWith(x)를 호출하게 되어 predicate가 false일 때 조차 불필요하게 연산을 한다.

3. 부수 효과

  • 초과 연산의 연장선 : 원하던 원하지 않던 predicate가 실행되기 때문에 부수효과가 일어날 수 있다.
  • predicate 함수로 인해 데이터를 생성하고, 원하지 않는 작업들이 수행될 수 있다.

추천하는 경우

1. 객체가 식이 아닐 때

  • takeIf()를 호출하는 것이 그저 값(객체)일 때 추천하지 않는 경우의 3가지 문제를 피할 수 있다.
  • 식에 대해서 takeIf()를 호출한 다면 에러를 유발하기 쉽다.
data class Person(val name: String, val age: Int)

fun main() {
    val person = Person("Alice", 25)

    val result = person.takeIf { it.age >= 18 }

    if (result != null) {
        println("Person is valid: $result")
    } else {
        println("Person is not valid")
    }
} 

2. 기타

// 수정 전
return if (evensOnly && x % 2 == 0) x else null
// 수정 후
return x.takeIf { evensOnly && x % 2 == 0}

// 수정 전
return if (someString.isNotBlank()) {
    someMoreWork(someString)
} else {
    null
}
// 수정 후
return someString.takeIf { it.isNotBlank() }?.let { someMoreWork(it) }

참고 사이트

profile
필요한 내용을 공부하고 저장합니다.

0개의 댓글