스코프 함수
스코프 함수 (Scope functions)
이번 챕터에서는 확장 함수에 대한 이해를 바탕으로, 스코프 함수(scope functions)를 사용해서 더 관용적인(idiomatic) 코드를 작성하는 법을 배워요.
출처: Kotlin 공식 문서
본문
스코프 함수 (Scope functions)
프로그래밍에서 스코프(scope)는 변수나 객체가 인식되는 영역을 뜻해요. 가장 자주 언급되는 스코프는 전역(global) 스코프와 지역(local) 스코프예요.
- 전역 스코프 – 프로그램 어디에서든 접근할 수 있는 변수나 객체.
- 지역 스코프 – 정의된 블록이나 함수 안에서만 접근할 수 있는 변수나 객체.
Kotlin에는 객체 주위에 임시 스코프를 만들고 그 안에서 코드를 실행하게 해 주는 스코프 함수가 있어요. 임시 스코프 안에서는 객체의 이름을 다시 언급할 필요가 없으니까 스코프 함수를 쓰면 코드가 더 간결해져요. 스코프 함수에 따라 객체에 접근하는 방법이 달라지는데, this 키워드로 참조하거나 it 키워드로 인자로서 사용할 수 있어요.
Kotlin에는 스코프 함수가 총 다섯 개 있어요: let, apply, run, also, with.
각 스코프 함수는 람다 표현식을 받고 객체 또는 람다 표현식의 결과를 돌려줘요. 이번 투어에서는 각 스코프 함수가 무엇이고 어떻게 쓰는지 하나씩 설명할게요.
스코프 함수에 대한 더 자세한 내용은 Kotlin 개발자 홍보 담당자 Sebastian Aigner의 Back to the Stdlib: Making the Most of Kotlin's Standard Library 강연을 볼 수도 있어요.
Let
코드에서 널 검사를 수행하고, 그 뒤에 돌려받은 객체로 추가 작업을 하고 싶을 때 let 스코프 함수를 사용해요.
이 예제를 볼게요.
fun sendNotification(recipientAddress: String): String {
println("Yo $recipientAddress!")
return "Notification sent!"
}
fun getNextAddress(): String {
return "[email protected]"
}
fun main() {
val address: String? = getNextAddress()
sendNotification(address)
}
이 예제에는 함수가 두 개 있어요.
sendNotification()– 함수 파라미터recipientAddress를 갖고 문자열을 돌려줘요.getNextAddress()– 함수 파라미터가 없고 문자열을 돌려줘요.
이 예제는 널 가능 String 타입인 address 변수를 만들어요. 하지만 sendNotification() 함수를 호출할 때 문제가 돼요. 이 함수는 address가 null 값일 수 있다고 예상하지 않기 때문이에요. 그래서 컴파일러가 오류를 보고해요.
Argument type mismatch: actual type is 'String?', but 'String' was expected.
입문 투어에서 if 조건으로 널 검사를 하거나 엘비스 연산자 ?:를 쓸 수 있다는 건 이미 배웠죠. 그런데 돌려받은 객체를 코드 뒷부분에서 사용하고 싶다면 어떨까요? if 조건과 else 분기로 이걸 구현할 수 있어요.
fun sendNotification(recipientAddress: String): String {
println("Yo $recipientAddress!")
return "Notification sent!"
}
fun getNextAddress(): String {
return "[email protected]"
}
fun main() {
//sampleStart
val address: String? = getNextAddress()
val confirm = if(address != null) {
sendNotification(address)
} else { null }
//sampleEnd
}
하지만 더 간결한 방법은 let 스코프 함수를 쓰는 거예요.
fun sendNotification(recipientAddress: String): String {
println("Yo $recipientAddress!")
return "Notification sent!"
}
fun getNextAddress(): String {
return "[email protected]"
}
fun main() {
//sampleStart
val address: String? = getNextAddress()
val confirm = address?.let {
sendNotification(it)
}
//sampleEnd
}
이 예제는:
address와confirm이라는 변수를 만들어요.address변수에let스코프 함수에 대한 안전 호출을 사용해요.let스코프 함수 안에 임시 스코프를 만들어요.sendNotification()함수를 람다 표현식으로let스코프 함수에 전달해요.- 임시 스코프를 사용해
it으로address변수를 참조해요. - 결과를
confirm변수에 할당해요.
이렇게 하면 코드가 address 변수가 null 값일 가능성을 처리할 수 있고, confirm 변수를 코드 뒷부분에서 사용할 수 있어요.
Apply
클래스 인스턴스 같은 객체를 코드 뒷부분에서 초기화하는 대신, 생성 시점에 초기화할 때 apply 스코프 함수를 사용해요. 이렇게 하면 코드를 읽고 관리하기가 더 쉬워져요.
이 예제를 볼게요.
class Client() {
var token: String? = null
fun connect() = println("connected!")
fun authenticate() = println("authenticated!")
fun getData() : String {
println("getting data!")
return "Mock data"
}
}
val client = Client()
fun main() {
client.token = "asdf"
client.connect()
// connected!
client.authenticate()
// authenticated!
client.getData()
// getting data!
}
이 예제에는 token 속성 하나와 connect(), authenticate(), getData()라는 멤버 함수 세 개를 가진 Client 클래스가 있어요.
이 예제는 Client 클래스의 인스턴스로 client를 만들고, main() 함수에서 그 token 속성을 초기화하고 멤버 함수들을 호출해요.
이 예제는 간결하지만, 실제 세계에서는 클래스 인스턴스(와 그 멤버 함수)를 만든 뒤 설정하고 사용하기까지 시간이 걸릴 수 있어요. 하지만 apply 스코프 함수를 쓰면 클래스 인스턴스를 만들고 설정하고 멤버 함수를 사용하는 일을 모두 코드의 같은 자리에서 처리할 수 있어요.
class Client() {
var token: String? = null
fun connect() = println("connected!")
fun authenticate() = println("authenticated!")
fun getData() : String {
println("getting data!")
return "Mock data"
}
}
//sampleStart
val client = Client().apply {
token = "asdf"
connect()
// connected!
authenticate()
// authenticated!
}
fun main() {
client.getData()
// getting data!
}
//sampleEnd
이 예제는:
Client클래스의 인스턴스로client를 만들어요.client인스턴스에apply스코프 함수를 사용해요.apply스코프 함수 안에 임시 스코프를 만들어서,client인스턴스의 속성이나 함수에 접근할 때 명시적으로client를 언급하지 않아도 되게 해요.token속성을 갱신하고connect()·authenticate()함수를 호출하는 람다 표현식을apply스코프 함수에 전달해요.main()함수에서client인스턴스의getData()멤버 함수를 호출해요.
보시다시피 이 전략은 커다란 코드 덩어리를 다룰 때 편리해요.
Run
apply와 비슷하게 run 스코프 함수로 객체를 초기화할 수도 있어요. 하지만 run은 코드의 특정 시점에 객체를 초기화하면서 즉시 결과를 계산할 때 쓰는 게 더 좋아요.
앞의 apply 함수 예제를 이어서 해볼게요. 이번에는 connect()와 authenticate() 함수를 묶어서 요청마다 호출되도록 하고 싶어요.
예를 들어 이렇게요.
class Client() {
var token: String? = null
fun connect() = println("connected!")
fun authenticate() = println("authenticated!")
fun getData() : String {
println("getting data!")
return "Mock data"
}
}
//sampleStart
val client: Client = Client().apply {
token = "asdf"
}
fun main() {
val result: String = client.run {
connect()
// connected!
authenticate()
// authenticated!
getData()
// getting data!
}
}
//sampleEnd
이 예제는:
Client클래스의 인스턴스로client를 만들어요.client인스턴스에apply스코프 함수를 사용해요.apply스코프 함수 안에 임시 스코프를 만들어서,client인스턴스의 속성이나 함수에 접근할 때 명시적으로client를 언급하지 않아도 되게 해요.token속성을 갱신하는 람다 표현식을apply스코프 함수에 전달해요.
main() 함수는:
String타입인result변수를 만들어요.client인스턴스에run스코프 함수를 사용해요.run스코프 함수 안에 임시 스코프를 만들어서,client인스턴스의 속성이나 함수에 접근할 때 명시적으로client를 언급하지 않아도 되게 해요.connect(),authenticate(),getData()함수를 호출하는 람다 표현식을run스코프 함수에 전달해요.- 결과를
result변수에 할당해요.
이제 돌려받은 결과를 코드 뒷부분에서 계속 사용할 수 있어요.
Also
로그를 남기는 것처럼 객체로 추가 동작을 수행하고, 그런 다음 객체를 돌려받아 코드에서 계속 사용하고 싶을 때 also 스코프 함수를 사용해요.
이 예제를 볼게요.
fun main() {
val medals: List<String> = listOf("Gold", "Silver", "Bronze")
val reversedLongUppercaseMedals: List<String> =
medals
.map { it.uppercase() }
.filter { it.length > 4 }
.reversed()
println(reversedLongUppercaseMedals)
// [BRONZE, SILVER]
}
이 예제는:
- 문자열 리스트를 담은
medals변수를 만들어요. List<String>타입인reversedLongUpperCaseMedals변수를 만들어요.medals변수에.map()확장 함수를 사용해요.it키워드로medals를 참조하고 그에.uppercase()확장 함수를 호출하는 람다 표현식을.map()함수에 전달해요.medals변수에.filter()확장 함수를 사용해요.it키워드로medals를 참조하고 리스트 안의 항목이 4자보다 많은지 확인하는 술어(predicate)로서 람다 표현식을.filter()함수에 전달해요.medals변수에.reversed()확장 함수를 사용해요.- 결과를
reversedLongUpperCaseMedals변수에 할당해요. reversedLongUpperCaseMedals변수가 담고 있는 리스트를 출력해요.
함수 호출 사이마다 로그를 좀 추가해서 medals 변수에 무슨 일이 일어나는지 보면 좋겠죠. also 함수가 그걸 도와줘요.
fun main() {
val medals: List<String> = listOf("Gold", "Silver", "Bronze")
val reversedLongUppercaseMedals: List<String> =
medals
.map { it.uppercase() }
.also { println(it) }
// [GOLD, SILVER, BRONZE]
.filter { it.length > 4 }
.also { println(it) }
// [SILVER, BRONZE]
.reversed()
println(reversedLongUppercaseMedals)
// [BRONZE, SILVER]
}
이제 이 예제는:
medals변수에also스코프 함수를 사용해요.also스코프 함수 안에 임시 스코프를 만들어서,medals변수를 함수 파라미터로 쓸 때 명시적으로 언급하지 않아도 되게 해요.it키워드로medals변수를 함수 파라미터로 사용해println()함수를 호출하는 람다 표현식을also스코프 함수에 전달해요.
also 함수는 객체를 돌려주기 때문에, 로깅뿐 아니라 디버깅, 여러 연산 연결(chaining), 그리고 코드의 주 흐름에 영향을 주지 않는 다른 부수 효과(side effect) 연산을 수행할 때도 유용해요.
With
다른 스코프 함수들과 달리 with는 확장 함수가 아니에요. 그래서 문법이 달라요. 수신 객체(receiver object)를 인자로 with에 전달해요.
객체에 여러 함수를 호출하고 싶을 때 with 스코프 함수를 사용해요.
이 예제를 볼게요.
class Canvas {
fun rect(x: Int, y: Int, w: Int, h: Int): Unit = println("$x, $y, $w, $h")
fun circ(x: Int, y: Int, rad: Int): Unit = println("$x, $y, $rad")
fun text(x: Int, y: Int, str: String): Unit = println("$x, $y, $str")
}
fun main() {
val mainMonitorPrimaryBufferBackedCanvas = Canvas()
mainMonitorPrimaryBufferBackedCanvas.text(10, 10, "Foo")
mainMonitorPrimaryBufferBackedCanvas.rect(20, 30, 100, 50)
mainMonitorPrimaryBufferBackedCanvas.circ(40, 60, 25)
mainMonitorPrimaryBufferBackedCanvas.text(15, 45, "Hello")
mainMonitorPrimaryBufferBackedCanvas.rect(70, 80, 150, 100)
mainMonitorPrimaryBufferBackedCanvas.circ(90, 110, 40)
mainMonitorPrimaryBufferBackedCanvas.text(35, 55, "World")
mainMonitorPrimaryBufferBackedCanvas.rect(120, 140, 200, 75)
mainMonitorPrimaryBufferBackedCanvas.circ(160, 180, 55)
mainMonitorPrimaryBufferBackedCanvas.text(50, 70, "Kotlin")
}
이 예제는 rect(), circ(), text() 멤버 함수 세 개를 가진 Canvas 클래스를 만들어요. 각 멤버 함수는 여러분이 전달한 함수 파라미터로 구성된 문장을 출력해요.
이 예제는 Canvas 클래스의 인스턴스로 mainMonitorPrimaryBufferBackedCanvas를 만들고, 그 인스턴스에 서로 다른 함수 파라미터로 멤버 함수들을 연속 호출해요.
이 코드는 읽기가 힘들다는 걸 알 수 있을 거예요. with 함수를 쓰면 코드가 정리돼요.
class Canvas {
fun rect(x: Int, y: Int, w: Int, h: Int): Unit = println("$x, $y, $w, $h")
fun circ(x: Int, y: Int, rad: Int): Unit = println("$x, $y, $rad")
fun text(x: Int, y: Int, str: String): Unit = println("$x, $y, $str")
}
fun main() {
//sampleStart
val mainMonitorSecondaryBufferBackedCanvas = Canvas()
with(mainMonitorSecondaryBufferBackedCanvas) {
text(10, 10, "Foo")
rect(20, 30, 100, 50)
circ(40, 60, 25)
text(15, 45, "Hello")
rect(70, 80, 150, 100)
circ(90, 110, 40)
text(35, 55, "World")
rect(120, 140, 200, 75)
circ(160, 180, 55)
text(50, 70, "Kotlin")
}
//sampleEnd
}
이 예제는:
mainMonitorSecondaryBufferBackedCanvas인스턴스를 수신자로with스코프 함수를 사용해요.with스코프 함수 안에 임시 스코프를 만들어서, 멤버 함수를 호출할 때mainMonitorSecondaryBufferBackedCanvas인스턴스를 명시적으로 언급하지 않아도 되게 해요.- 서로 다른 함수 파라미터로 멤버 함수들을 연속 호출하는 람다 표현식을
with스코프 함수에 전달해요.
이제 이 코드는 훨씬 읽기 쉬워지고, 실수할 가능성도 줄어들어요.
사용 사례 개요 (Use case overview)
이번 섹션에서 Kotlin의 다양한 스코프 함수와, 코드를 더 관용적으로 만들기 위한 주요 사용 사례들을 다뤘어요. 아래 표를 빠른 참고 자료로 쓰면 돼요. 이 함수들이 어떻게 작동하는지 완전히 이해하지 않아도 코드에서 사용하는 데는 문제가 없다는 점을 기억해 두세요.
| Function | Access to x via |
Return value | Use case |
|---|---|---|---|
let |
it |
Lambda result | 코드에서 널 검사를 수행하고 돌려받은 객체로 추가 동작을 수행할 때. |
apply |
this |
x |
객체를 생성 시점에 초기화할 때. |
run |
this |
Lambda result | 객체를 생성 시점에 초기화하고 결과도 계산할 때. |
also |
it |
x |
객체를 돌려주기 전에 추가 동작을 수행할 때. |
with |
this |
Lambda result | 객체에 여러 함수를 호출할 때. |
스코프 함수에 대한 더 자세한 내용은 Scope functions 문서를 참고해요.
연습 (Practice)
연습문제 1 (Exercise 1)
.getPriceInEuros() 함수를 안전 호출 연산자 ?.와 let 스코프 함수를 사용하는 단일 표현식 함수(single-expression function)로 다시 작성해요.
힌트: getProductInfo() 함수에서 priceInDollars 속성에 안전하게 접근하려면 안전 호출 연산자 ?.를 사용해요. 그런 다음 let 스코프 함수를 사용해 priceInDollars의 값을 유로로 변환해요.
data class ProductInfo(val priceInDollars: Double?)
class Product {
fun getProductInfo(): ProductInfo? {
return ProductInfo(100.0)
}
}
// Rewrite this function
fun Product.getPriceInEuros(): Double? {
val info = getProductInfo()
if (info == null) return null
val price = info.priceInDollars
if (price == null) return null
return convertToEuros(price)
}
fun convertToEuros(dollars: Double): Double {
return dollars * 0.85
}
fun main() {
val product = Product()
val priceInEuros = product.getPriceInEuros()
if (priceInEuros != null) {
println("Price in Euros: €$priceInEuros")
// Price in Euros: €85.0
} else {
println("Price information is not available.")
}
}
data class ProductInfo(val priceInDollars: Double?)
class Product {
fun getProductInfo(): ProductInfo? {
return ProductInfo(100.0)
}
}
fun Product.getPriceInEuros() = getProductInfo()?.priceInDollars?.let { convertToEuros(it) }
fun convertToEuros(dollars: Double): Double {
return dollars * 0.85
}
fun main() {
val product = Product()
val priceInEuros = product.getPriceInEuros()
if (priceInEuros != null) {
println("Price in Euros: €$priceInEuros")
// Price in Euros: €85.0
} else {
println("Price information is not available.")
}
}
연습문제 2 (Exercise 2)
사용자의 이메일 주소를 갱신하는 updateEmail() 함수가 있어요. apply 스코프 함수로 이메일 주소를 갱신한 다음, also 스코프 함수로 Updating email for user with ID: ${it.id} 로그 메시지를 출력해요.
data class User(val id: Int, var email: String)
fun updateEmail(user: User, newEmail: String): User = // Write your code here
fun main() {
val user = User(1, "[email protected]")
val updatedUser = updateEmail(user, "[email protected]")
// Updating email for user with ID: 1
println("Updated User: $updatedUser")
// Updated User: User(id=1, [email protected])
}
data class User(val id: Int, var email: String)
fun updateEmail(user: User, newEmail: String): User = user.apply {
this.email = newEmail
}.also { println("Updating email for user with ID: ${it.id}") }
fun main() {
val user = User(1, "[email protected]")
val updatedUser = updateEmail(user, "[email protected]")
// Updating email for user with ID: 1
println("Updated User: $updatedUser")
// Updated User: User(id=1, [email protected])
}