마흔두 번째, 범위 함수입니다.
※ scope는 프로그래밍 언어에서 특정 대상이 효력이 있는 범위를 얘기할 때 사용되는 단어입니다. 그래서, 보통 '변수의 유효 범위' 식으로 유효 범위로도 많이 얘기합니다. 본 연재에서도 범위나 유효 범위를 혼용하여 사용하고 있습니다. 그리고, 범위 함수는 범위 지정 함수라고 하는 경우도 많고, 그냥 영어 그대로 스코프 함수라고 얘기하는 경우도 많을 겁니다.
Kotlin 표준 라이브러리에는 오로지 객체의 문맥 내에서 코드 블록을 실행하기 위한 목적을 가진 몇몇 함수가 포함돼 있습니다. 이런 함수에 람다 표현식을 제공하면 객체에 대해서 호출할 때는, 임시적인 범위를 만듭니다. 이 범위 내에서는 객체를 이름 없이 접근할 수 있습니다. 이런 함수들을 범위 함수(scope functions)라고 부릅니다. 범위 함수 중에는 let, run, with, apply, also 라는 다섯 가지 함수가 있습니다.
기본적으로 이 함수들은 모두 객체에 대한 코드 블록 실행이라는 같은 동작을 수행합니다. 다른 점은 객체가 블록 내에서 어떻게 사용 가능하게 되느냐와 전체 표현식의 결과가 무엇이냐입니다.
다음은 범위 함수를 사용하는 전형적인 예입니다.
data class Person(var name: String, var age: Int, var city: String) {
fun moveTo(newCity: String) { city = newCity }
fun incrementAge() { age++ }
}
fun main() {
Person("Alice", 20, "Amsterdam").let {
println(it)
it.moveTo("London")
it.incrementAge()
println(it)
}
}
/* 결과
Person(name=Alice, age=20, city=Amsterdam)
Person(name=Alice, age=21, city=London)
*/
만약, let 없이 같은 내용의 코드를 작성한다면, 다음과 같이 새로운 변수를 추가해야 하고, 객체를 사용할 때마다 객체 이름을 반복해야 합니다.
val alice = Person("Alice", 20, "Amsterdam")
println(alice)
alice.moveTo("London")
alice.incrementAge()
println(alice)
범위 함수는 어떤 새로운 기술적 능력도 추가해 주지 않습니다. 하지만, 코드를 보다 간결하고 가독성 있게 해 줍니다.
범위 함수들은 유사성이 많기 때문에 어떤 경우에 어떤 것을 선택해야 하는지가 까다로울 수 있습니다. 선택은 주로 작성자의 의도와 프로젝트 내에서 일관적인 사용에 달려 있습니다. 아래에서, 범위 함수들 간의 차이점과 사용 관례에 대해 상세히 다루도록 하겠습니다.
함수 선택
다음은 목적에 맞는 적절한 범위 함수 선택을 돕기 위해 만들어진 함수 간의 주요 차이점을 요약한 표입니다.
함수 | 객체 참조 | 반환 값 | 확장 함수 여부 |
let | it | 람다 결과 | 예 |
run | this | 람다 결과 | 예 |
run | - | 람다 결과 | 아니오: 문맥 객체 없이 호출합니다. |
with | this | 람다 결과 | 아니오: 문맥 객체를 인수로 받습니다. |
apply | this | 문맥 객체 | 예 |
also | it | 문맥 객체 | 예 |
각 함수의 상세 정보는 아래에 해당 섹션들에서 설명하고 있습니다.
다음은 목적에 따라 함수를 선택하는 짧은 가이드입니다.
- 널 가능하지 않은 객체에 대한 람다 실행: let
- 객체를 지역 범위에서 새로운 이름으로 사용할 때: let
- 객체 설정: apply
- 객체 설정과 결과 계산: run
- 표현식이 요구되는 곳에서 문장(statement) 실행: 확장 함수가 아닌 run
- 부가적인 효과: also
- 객체에 대한 함수 호출들을 묶을 때: with
범위 함수 사용에 대한 다양한 사례가 중복될 수 있기 때문에 프로젝트나 팀에서 사용하는 구체적인 규칙에 맞추어 함수를 선택할 수 있습니다.
범위 함수는 코드를 보다 간결하게 해 주기는 하지만 남용하지 말아야 합니다. 남용하면 코드를 읽기 어렵게 하고 오류를 발생 시킬 수 있습니다. 또한, 중첩된 사용을 피해야 하고, 연쇄적으로 사용할 때는 현재의 문맥 객체와 this나 it의 값을 혼동하기 쉽기 때문에 주의해야 합니다.
차이점
범위 함수들은 실제로 많이 유사하기 때문에 그들 간의 차이점을 이해하는 것은 중요합니다. 각각의 범위 함수 간에는 다음과 같은 두 개의 주요 차이점이 있습니다.
- 문맥 객체를 참조하는 방법
- 반환값
문맥 객체: this 또는 it
범위 함수에 전달된 람다 내부에서는 문맥 객체를 실제 이름 대신 보다 간단하게 참조할 수 있습니다. 각각의 범위 함수는 문맥 객체 참조에 대하여 두 가지 방법 중 하나를 사용합니다. 하나는 람다의 수신자(this)이고 다른 하나는 람다의 인수(it)입니다. 둘 다 같은 기능을 제공합니다. 그래서, 경우마다 각각의 장단점이 무엇인지와 권장하는 사용 방법을 얘기해 보도록 하겠습니다.
fun main() {
val str = "Hello"
// this
str.run {
println("The string's length: $length") // this 생략
//println("The string's length: ${this.length}") // this를 명시적 사용
}
// it
str.let {
println("The string's length is ${it.length}")
}
}
/* 결과
The string's length: 5
The string's length is 5
*/
this
run, with, apply는 문맥 객체를 람다 수신자(this 키워드)로 참조하게 합니다. 그러므로, 함수에 넘겨지는 람다에서는 문맥 객체를 일반적인 클래스의 멤버 함수에서 사용하는 것처럼 사용할 수 있습니다.
대부분의 경우 수신자 객체의 멤버를 접근할 때는 this를 생략해서 코드를 보다 짧게 할 수 있습니다. 다른 한 편으로 this를 생략하면, 수신자의 멤버와 외부 객체나 함수 간에 구분이 어려워질 수 있습니다. 그러므로, 문맥 객체를 수신자(this)로 갖는 경우에는 주로 객체의 멤버 함수를 호출하는 작업이나 프로퍼티에 값을 할당하는 람다 사용이 권고됩니다.
data class Person(var name: String, var age: Int = 0, var city: String = "")
fun main() {
val adam = Person("Adam").apply {
age = 20 // this.age = 20과 같습니다.
city = "London"
}
println(adam)
}
/* 결과
Person(name=Adam, age=20, city=London)
*/
it
let과 also는 문맥 객체를 람다의 인수로 참조하게 합니다. 인수의 이름이 지정되지 않으면, 객체는 묵시적 기본 이름인 it로 접근됩니다. it는 this 보다 짧고 it을 사용하는 표현식은 보통 보다 읽기 쉽습니다.
하지만, 객체의 함수나 프로퍼티를 호출할 때, this 같이 묵시적으로 사용할 수 있는 문맥 객체가 없습니다. 그러므로, it을 통한 문맥 객체 접근은 객체가 대부분의 함수 호출의 인수로 사용될 때, 보다 유용합니다. 또한 it는 코드 블록에서 여러 변수를 사용할 때 더 좋습니다.
import kotlin.random.Random
fun writeToLog(message: String) {
println("INFO: $message")
}
fun main() {
fun getRandomInt(): Int {
return Random.nextInt(100).also {
writeToLog("getRandomInt() generated value $it")
}
}
val i = getRandomInt()
println(i)
}
/* 결과
INFO: getRandomInt() generated value 44
44
*/
다음의 예는 문맥 객체를 람다의 매개변수 value에 인수로 받아 참조하는 것을 보여줍니다.
import kotlin.random.Random
fun writeToLog(message: String) {
println("INFO: $message")
}
fun main() {
fun getRandomInt(): Int {
return Random.nextInt(100).also { value ->
writeToLog("getRandomInt() generated value $value")
}
}
val i = getRandomInt()
println(i)
}
반환 값
범위 함수는 반환하는 것이 다음과 같이 각각 다릅니다.
- apply와 also는 문맥 객체를 반환합니다.
- let, run, with는 람다의 결과를 반환합니다.
코드에서 다음에 무엇을 할지에 따라 반환값을 주의 깊게 고려해야 합니다. 다음의 내용이 최선의 선택을 할 수 있도록 도울 것입니다.
문맥 객체 (Context object)
apply와 also가 반환하는 값은 문맥 객체 자체입니다. 그러므로, 이 함수들은 연쇄적인 호출의 한 단계로 추가될 수 있습니다.
fun main() {
val numberList = mutableListOf<Double>()
numberList.also { println("Populating the list") }
.apply {
add(2.71)
add(3.14)
add(1.0)
}
.also { println("Sorting the list") }
.sort()
println(numberList)
}
/* 결과
Populating the list
Sorting the list
[1.0, 2.71, 3.14]
*/
또한 apply와 also는 문맥 객체를 반환하는 함수의 return 문에 사용할 수 있습니다.
import kotlin.random.Random
fun writeToLog(message: String) {
println("INFO: $message")
}
fun main() {
fun getRandomInt(): Int {
return Random.nextInt(100).also {
writeToLog("getRandomInt() generated value $it")
}
}
val i = getRandomInt()
}
람다의 결과
let, run, with는 람다의 결과를 반환합니다. 그래서, 이 함수들은 람다의 결과를 변수에 할당할 때나 람다 결과에 대한 연속된 작업을 할 때 등등에 사용할 수 있습니다.
fun main() {
val numbers = mutableListOf("one", "two", "three")
val countEndsWithE = numbers.run {
add("four")
add("five")
count { it.endsWith("e") }
}
println("There are $countEndsWithE elements that end with e.")
}
/* 결과
There are 3 elements that end with e.
*/
부가적으로 반환값은 무시하고, 단지 지역 변수를 위한 임시 범위를 만들기 위해 범위 함수를 사용할 수 있습니다.
fun main() {
val numbers = mutableListOf("one", "two", "three")
with(numbers) {
val firstItem = first()
val lastItem = last()
println("First item: $firstItem, last item: $lastItem")
}
}
/* 결과
First item: one, last item: three
*/
함수들
올바른 범위 함수를 선택할 수 있도록 함수별 상세 내용과 권고사항을 기술합니다. 기술적으로 범위 함수는 많은 경우에 서로 바꿔서 사용할 수 있으므로, (정답이 있는 것이 아니고) 예제에서는 함수를 사용하는 관례를 보여줍니다.
let
- 문맥 객체는 인수로 사용 가능합니다(it)
- 반환 값은 람다의 결과입니다.
let은 하나나 그 이상의 연속된 함수 호출에 사용할 수 있습니다. 예를 들어, 다음의 코드는 컬렉션에 두 개의 연산을 수행한 결과를 출력합니다.
fun main() {
val numbers = mutableListOf("one", "two", "three", "four", "five")
val resultList = numbers.map { it.length }.filter { it > 3 }
println(resultList)
}
/* 결과
[5, 4, 4]
*/
let을 사용하면 리스트 연산의 결과를 변수에 할당하지 않고도 동작할 수 있게 코드를 재작성 할 수 있습니다.
fun main() {
val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3 }.let {
println(it)
// 필요한 경우 더 많은 함수 호출
}
}
/* 결과
[5, 4, 4]
*/
let에 전달되는 코드 블록이 it을 인수로 갖는 하나의 함수만 갖는 경우에는, 람다 인수 대신 메소드 참조(::)를 사용할 수 있습니다.
fun main() {
val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3 }.let(::println)
}
let은 종종 널이 아닌 값을 포함하는 코드 블록을 실행하는 데 사용할 수 있습니다. 널이 아닌 객체에 필요한 작업을 하기 위해서 안전 호출 연산자 ?.를 사용하고, 필요한 작업을 하는 람다를 갖는 let을 호출합니다.
fun processNonNullString(str: String) {}
fun main() {
val str: String? = "Hello"
//processNonNullString(str) // 컴파일 오류: str은 널 일수 있습니다.
val length = str?.let {
println("let() called on $it")
processNonNullString(it) // OK: 'it'는'?.let { }' 내부에서 널이 아닙니다.
it.length
}
}
let은 또한 코드의 가독성을 높이기 위해 제한된 범위로 지역 변수를 추가할 때 사용할 수 있습니다. 문맥 객체를 위한 새로운 변수를 정의하기 위해서 람다의 매개변수를 정의합니다. 이렇게 하면 it 대신 해당 매개변수 이름을 사용할 수 있습니다(it는 람다의 단일 매개변수를 위한 묵시적 이름이므로 당연한 얘기이기는 합니다).
fun main() {
val numbers = listOf("one", "two", "three", "four")
val modifiedFirstItem = numbers.first().let { firstItem ->
println("The first item of the list is '$firstItem'")
if (firstItem.length >= 5) firstItem else "!" + firstItem + "!"
}.uppercase()
println("First item after modifications: '$modifiedFirstItem'")
}
/* 결과
The first item of the list is 'one'
First item after modifications: '!ONE!'
*/
with
- 문맥 객체는 수신자(this)로 사용 가능합니다.
- 반환 값은 람다의 결과입니다.
with는 확장 함수가 아니기 때문에 문맥 객체가 인수로 전달됩니다. 하지만, 람다 안에서는 문맥 객체는 수신자(this)로 사용할 수 있습니다.
with는 문맥 객체에 대해서 반환 값을 사용하지 않는 함수를 호출할 때 사용하는 것을 추천합니다. 코드에서 with는 "이 객체와 함께 다음을 실행한다"로 읽힐 수 있습니다.
fun main() {
val numbers = mutableListOf("one", "two", "three")
with(numbers) {
println("'with' is called with argument $this")
println("It contains $size elements")
}
}
/* 결과
'with' is called with argument [one, two, three]
It contains 3 elements
*/
with는 또한 문맥 객체를 연산되는 값을 위한 도우미로 다룰 때 사용할 수 있습니다. 즉, 원하는 값의 산출을 위해 문맥 객체의 멤버 함수나 프로퍼티를 사용합니다.
fun main() {
val numbers = mutableListOf("one", "two", "three")
val firstAndLast = with(numbers) {
"The first element is ${first()}," +
" the last element is ${last()}"
}
println(firstAndLast)
}
/* 결과
The first element is one, the last element is three
*/
run
- 문맥 객체는 수신자(this)로 사용 가능합니다.
- 반환 값은 람다의 결과입니다.
run은 with와 같게 동작합니다. 다만, run은 확장 함수로 구현됩니다. 그래서, let 처럼 문맥 객체에 대해 마침표 표기법으로 사용할 수 있습니다.
run은 문맥 객체를 초기화 하고 반환값을 만들 때 유용합니다.
class MultiportService(var url: String, var port: Int) {
fun prepareRequest(): String = "Default request"
fun query(request: String): String = "Result for query '$request'"
}
fun main() {
val service = MultiportService("https://example.kotlinlang.org", 80)
val result = service.run {
port = 8080
query(prepareRequest() + " to port $port")
}
// 같은 코드를 let() 함수를 사용하여 작성
val letResult = service.let {
it.port = 8080
it.query(it.prepareRequest() + " to port ${it.port}")
}
println(result)
println(letResult)
}
/* 결과
Result for query 'Default request to port 8080'
Result for query 'Default request to port 8080'
*/
run은 또한 확장 함수가 아닌 방법으로 호출할 수 있습니다. run의 확장 함수가 아닌 형태의 변형은 문맥 객체를 갖지 않지만 여전히 람다의 결과를 반환합니다. 확장 함수가 아닌 형태의 run은 표현식이 요구되는 곳에 복수의 문장(statement) 블록을 사용할 수 있게 해 줍니다. 코드에서 확장 함수가 아닌 run은 "코드 블록을 실행하고 결과 값을 계산한다"로 읽힐 수 있습니다.
fun main() {
// = 오른쪽에 표현식이 와야 하므로
// val digits = "0-9" 같은 문장들은 올 수가 없습니다.
// 하지만 run을 통해 문장들을 블록으로 묶고 반환값을 갖게 됨으로서
// run + 블록이 하나의 표현식이 되어 = 오른쪽에 사용 가능합니다.
val hexNumberRegex = run {
val digits = "0-9"
val hexDigits = "A-Fa-f"
val sign = "+-"
Regex("[$sign]?[$digits$hexDigits]+")
}
for (match in hexNumberRegex.findAll("+123 -FFFF !%*& 88 XYZ")) {
println(match.value)
}
}
/* 결과
+123
-FFFF
88
*/
apply
- 문맥 객체는 수신자(this)로 사용 가능합니다.
- 반환 값은 문맥 객체 자체입니다.
apply는 문맥 객체 자체를 반환하기 때문에, 값을 반환 하지 않고 주로 수신자 객체의 멤버들을 다루는 코드 블록에 사용하는 것을 추천합니다. apply를 사용하는 대부분의 공통적인 사례는 객체 설정입니다. 이렇게 apply가 호출될 때는 "객체에 다음의 할당들을 적용한다"로 읽을 수 있습니다.
data class Person(var name: String, var age: Int = 0, var city: String = "")
fun main() {
val adam = Person("Adam").apply {
age = 32
city = "London"
}
println(adam)
}
/* 결과
Person(name=Adam, age=32, city=London)
*/
또, 다른 apply의 사용 예는 보다 복잡한 처리를 위해 연쇄적인 호출에 포함시키는 것입니다.
val numberList = mutableListOf<Double>()
numberList.also { println("Populating the list") }
.apply {
add(2.71)
add(3.14)
add(1.0)
}
.also { println("Sorting the list") }
.sort()
also
- 문맥 객체는 인수로 사용 가능합니다(it)
- 반환 값은 문맥 객체 자체입니다.
also는 문맥 객체를 인수로 받아서 어떤 동작을 실행할 때 유용합니다. also를 문맥 객체의 멤버 함수나 프로퍼티 보다는 문맥 객체의 참조에 대해서 뭔가를 해야 할 때 사용합니다. 또는 바깥 범위의 this 참조가 가려지지(shadow) 않았으면 할 때 사용합니다.
코드에서 also를 보면, "그리고 또한 객체와 다음의 것들을 수행한다"라고 읽을 수 있습니다.
fun main() {
val numbers = mutableListOf("one", "two", "three")
numbers
.also { println("The list elements before adding new one: $it") }
.add("four")
}
class Foo(val baz: Int) {
fun calculateWithBaz() {
val numbers = mutableListOf("one", "two", "three")
numbers
.also {
// apply를 사용했다면, 외부 this가 수신 객체로 가려지기 때문에 이렇게 사용하지 못합니다.
println("baz: ${this.baz}")
println("The list elements before adding new one: $it")
}
.add("four")
}
}
takeIf 와 takeUnless
표준 라이브러리에는 추가적인 범위 함수로 takeIf와 takeUnless가 있습니다. 이 함수들 연쇄 호출 중간에 객체의 상태 검사를 추가할 수 있게 해 줍니다.
어떤 객체에 대해서 프레디케이트(predicate)와 함께 호출 됐을 때, takeIf는 주어진 프레디케이트를 만족할 때 객체를 반환하고 그렇지 않으면 null을 반환합니다. 그래서, takeIf는 단일 객체에 대한 필터 함수입니다.
takeUnless는 takeIf와 반대 로직입니다. 어떤 객체에 대해선 프레디케이트와 함께 호출 됐을 때, 만족하면 null을 반환하고 그렇지 않으면 객체를 반환합니다.
takeIf나 takeUnless를 사용할 때, 객체는 람다의 인수(it)로 사용할 수 있습니다.
import kotlin.random.*
fun main() {
val number = Random.nextInt(100)
val evenOrNull = number.takeIf { it % 2 == 0 }
val oddOrNull = number.takeUnless { it % 2 == 0 }
println("even: $evenOrNull, odd: $oddOrNull")
}
/* 결과. 임의의 숫자가 75가 된 경우
even: null, odd: 75
*/
※ takeIf나 takeUnless는 null을 반환할 수 있기 때문에, 뒤에 연쇄적인 호출을 하는 경우 해당 함수에서 널 검사를 하거나 안전 호출(?.)을 해야 하는 것을 잊어서는 안 됩니다.
fun main() {
val str = "Hello"
val caps = str.takeIf { it.isNotEmpty() }?.uppercase()
//val caps = str.takeIf { it.isNotEmpty() }.uppercase() // 컴파일 오류
println(caps)
}
/* 결과
HELLO
*/
takeIf와 takeUnless는 특히 범위 함수와 같이 사용할 때 유용합니다. 예를 들어, takeIf와 takeUnless를 let과 함께 사용하여 주어진 프레디케이트를 만족하는 객체에 대해서 코드 블록을 실행할 수 있습니다. 이렇기 하기 위해서, 객체에 대해 takeIf를 호출하고, let을 ?.로 호출합니다. 객체가 프레디케이트와 일치하지 않으면 takeIf는 null을 반환하고, let은 실행되지 않습니다.
fun main() {
fun displaySubstringPosition(input: String, sub: String) {
input.indexOf(sub).takeIf { it >= 0 }?.let {
println("The substring $sub is found in $input.")
println("Its start position is $it.")
}
}
displaySubstringPosition("010000011", "11")
displaySubstringPosition("010000011", "12")
}
/* 결과
The substring 11 is found in 010000011.
Its start position is 7.
*/
비교를 위해, 다음은 takeIf와 let 없이 같은 작업을 하는 코드입니다.
fun main() {
fun displaySubstringPosition(input: String, sub: String) {
val index = input.indexOf(sub)
if (index >= 0) {
println("The substring $sub is found in $input.")
println("Its start position is $index.")
}
}
displaySubstringPosition("010000011", "11")
displaySubstringPosition("010000011", "12")
}
범위 함수는 적절히 사용하면 유용합니다. 다만, 익숙해 지기 전까지는 매번 뭘 사용해야 할 지 문서를 봐야 할 수 있습니다. IDE의 친절한 인도 외에 위의 함수 선택 부분의 표가 익숙해 지는데 도움이 될 수 있습니다. 그리고, (번역이 좀 그렇기는 하지만) 각 함수별로 어떻게 읽을 수 있다는 부분을 기억하면, 선택 및 사용 방법을 기억하는 데 도움이 됩니다. 대부분 해당 함수 이름의 뜻과 연계되기 때문에 조금만 주의를 기울이면 비교적 쉽게 외울 수 있습니다(예, apply는 객체에 할당들을 적용할 때 사용한다)
'Kotlin' 카테고리의 다른 글
공식 문서로 배우는 코틀린 - 41. Reflection (0) | 2024.03.17 |
---|---|
공식 문서로 배우는 코틀린 - 40. Destructuring declarations (0) | 2024.03.17 |
공식 문서로 배우는 코틀린 - 39. Annotations (0) | 2024.03.16 |
공식 문서로 배우는 코틀린 - 38. This expressions (0) | 2024.03.16 |
공식 문서로 배우는 코틀린 - 37. Equality (0) | 2024.03.16 |