목차
개요
Kotlin에는 Java에는 존재하지 않는 스코프 함수(Scope Function)라는 특수한 함수가 있다. 스코프 함수는 단어 의미 그대로 번역하면 범위 함수가 되는데, 어떠한 객체에 대하여 이 객체가 다루는 특정 범위를 생성하여 프로퍼티나 메소드를 처리하는 코드를 간결하게 만들거나, 메소드 체이닝에 활용하는 함수를 의미한다. 이번 포스트에서는 스코프 함수의 종류와 그 특징에 대해서 정리한다.
스코프 함수의 종류
스코프 함수에는 let
, run
, also
, apply
, with
와 같이 총 5개의 종류가 있다. 이 스코프 함수들은 모두 함수를 파라미터로 받아서 동작하는데, with
를 제외한 나머지 4 종류는 반환값과 파라미터로 받는 함수의 형태에 따라 다음과 같이 분류할 수 있다. 여기서 람다 형태는 각 스코프 함수가 파라미터로 받는 람다 함수의 형태를 의미한다.
with
를 제외한 나머지 4 종류의 스코프 함수는 모두 확장 함수로 동작한다.
let
let
은 파라미터로 받는 block이라는 함수가 원본 객체를 파라미터로 받는 일반 함수이며, 해당 함수의 동작을 수행한 후 함수의 반환값을 반환한다. 파라미터로 접근하기 위해선 기본적으로 it
을 사용하지만 다른 람다 함수와 마찬가지로 이 이름은 변경할 수 있다. 다음 예시 코드는 let
을 사용하는 대표적인 예시이다.
Person("James", 20, "Amsterdam").let {
println(it)
it.moveTo("London")
it.incrementAge()
println(it)
}
/*
Person(name=James, age=20, city=Amsterdam)
Person(name=James, age=21, city=London)
*/
이 코드에서 파라미터로 접근하기 위한 식별자를 아래와 같이 변경할 수도 있다.
Person("James", 20, "Amsterdam").let { p ->
println(p)
p.moveTo("London")
p.incrementAge()
println(p)
}
이 코드는 초기화된 Person 객체에 대하여 해당 객체를 출력한 후 객체에 여러 동작을 수행했다가 다시 객체를 출력한다. 이를 일반적인 코드로 작성하면 다음과 같다.
val person = Person("James", 20, "Amsterdam")
println(person)
person.moveTo("London")
person.incrementAge()
println(person)
/*
Person(name=James, age=20, city=Amsterdam)
Person(name=James, age=21, city=London)
*/
이 두 코드는 완전히 같은 동작을 수행한다.
let
의 람다에서 반환값을 받아올 때는 return
을 사용하지 않고 블록 마지막에 반환할 객체를 지정해주면 된다.
val age = Person("James", 20, "Amsterdam").let {
println(it)
it.moveTo("London")
it.incrementAge()
println(it)
it.age
}
println(age)
/*
Person(name=James, age=20, city=Amsterdam)
Person(name=James, age=21, city=London)
21
*/
run
run
은 파라미터로 받는 block이라는 함수가 원본 객체에 대한 확장 함수이며, 해당 함수의 동작을 수행한 후 함수의 반환값을 반환한다. let
과의 차이점은 파라미터로 받는 함수가 확장 함수이기 때문에 원본 객체에 접근하려면 this
키워드를 사용해야 하며 이를 수정할 수 없다는 점이다. 대신 원본 객체의 멤버에 접근할 때 클래스에서 멤버에 접근하는 것처럼 this
를 생략할 수 있다. 위 let
에서의 예시 코드에서 run
을 사용하면 다음과 같은 모습이 된다.
Person("James", 20, "Amsterdam").run {
println(this)
moveTo("London") // this.moveTo()로 사용 가능
incrementAge() // this.incrementAge()로 사용 가능
println(this)
}
/*
Person(name=James, age=20, city=Amsterdam)
Person(name=James, age=21, city=London)
*/
run
역시 람다에서 반환값을 받아올 때는 return
을 사용하지 않고 블록 마지막에 반환할 객체를 지정해주면 된다.
val age = Person("James", 20, "Amsterdam").run {
println(this)
moveTo("London")
incrementAge()
println(this)
age
}
println(age)
/*
Person(name=James, age=20, city=Amsterdam)
Person(name=James, age=21, city=London)
21
*/
run
은 다른 스코프 함수와 달리 형태가 한 가지 더 있다.
이 두번째 run
은 확장 함수도 아니고 아무런 파라미터도 받지 않는다. 단순히 block 내부의 동작을 수행한 후 block의 반환값을 반환할 뿐이다. 이 함수는 객체를 생성할 때 관련 코드들을 한 블럭에 묶어서 가독성을 높이는 역할을 한다.
val person = run {
val name = "James"
val age = 20
val city = "Amsterdam"
Person(name, age, city)
}
이 코드를 일반적인 코드로 바꾸면 다음과 같다.
val name = "James"
val age = 20
val city = "Amsterdam"
val person = Person(name, age, city)
also
also
는 파라미터로 받는 block이라는 함수가 원본 객체를 파라미터로 받는 일반 함수이며, block 내부의 동작을 수행한 후 원본 객체를 그대로 반환한다. also
역시 let
과 마찬가지로 it
을 사용해 원본 객체의 멤버에 접근하며, 다른 이름으로 변경할 수도 있다. also
를 사용하는 예시는 다음과 같다.
val list = ArrayList<Int>().also {
it.add(1)
it.add(2)
it.add(3)
}
println(list)
/*
[1, 2, 3]
*/
also
는 원본 객체를 그대로 반환한다는 점으로 인해 여러 방법으로 사용할 수 있다. 가령, while
의 조건식에서 EOF까지 한 줄씩 입력을 받고 그 입력에 대한 동작을 수행해야 할 때 아래와 같은 방식으로 나타낼 수 있다.
var str: String? = ""
while (readLine().also { str = it }?.isEmpty() == false) {
...
}
이 코드에서는 한 줄로 받은 입력을 str에 저장하면서 그대로 그 입력에 대하여 null 여부와 비어있는지 여부를 확인하게 된다. also
를 사용하지 않고 일반적인 방식으로 나타낸다면 다음과 같이 표현될 것이다.
var str: String?
while (true) {
str = readLine()
if (str?.isEmpty() != false) {
break
}
...
}
also
는 원본 객체를 반환한다는 특성 덕분에 다음과 같이 두 변수를 교환할 때도 사용할 수 있다.
fun main() {
var a = 10
var b = 20
println("a = $a, b = $b")
b = a.also { a = b }
println("a = $a, b = $b")
}
/*
a = 10, b = 20
a = 20, b = 10
*/
apply
apply
는 파라미터로 받는 block이라는 함수가 원본 객체의 확장 함수이며, block 내부의 동작을 수행한 후 원본 객체를 반환한다. apply
도 run
과 마찬가지로 원본 객체의 멤버에 접근하기 위해서는 기본적으로 this
키워드를 사용해야하지만 원본 객체의 멤버에 접근할 때 this
를 생략할 수 있다. 위 also
의 경우를 apply
를 사용하면 다음과 같이 표현된다.
val list = ArrayList<Int>().apply {
add(1)
add(2)
add(3)
}
println(list)
/*
[1, 2, 3]
*/
apply
또한 also
와 마찬가지로 원본 객체를 반환한다는 특성 덕분에 두 변수를 교환할 때 사용할 수 있다.
fun main() {
var a = 10
var b = 20
println("a = $a, b = $b")
b = a.apply { a = b }
println("a = $a, b = $b")
}
/*
a = 10, b = 20
a = 20, b = 10
*/
with
with
는 다른 스코프 함수와는 달리 확장 함수가 아닌 일반 함수이다. 어떤 객체 receiver를 파라미터로 받고, receiver에 대한 확장 함수를 파라미터로 받아서 block 내부의 동작을 수행하고 해당 함수의 반환값을 반환하게 된다. with
가 다른 스코프 함수와 갖는 가장 큰 차이점은 객체를 파라미터로 받는 일반 함수이기 때문에 해당 receiver는 반드시 non-null이라는 것을 보장해줘야 정상적으로 작동하게 된다. 다른 4 종류의 스코프 함수는 확장 함수이기 때문에 safe call(.?)이 가능하고 elvis 연산자(?:)를 통해 해당 객체가 null일 경우에 대한 동작을 지정할 수 있지만 with
는 이러한 null 검사가 불가능하다.
따라서 with
는 non-null인 객체에 대하여 해당 객체의 프로퍼티나 메소드를 호출하는 코드들을 하나의 블록으로 묶을 때 사용할 수 있고, 그렇게 묶은 블록의 연산을 수행한 최종 결과값을 받아올 때도 사용할 수 있다.
val person = Person("James", 20, "Amsterdam")
with(person) {
println(this)
moveTo("London")
incrementAge()
println(this)
}
/*
Person(name=James, age=20, city=Amsterdam)
Person(name=James, age=21, city=London)
*/
마무리
이상으로 Kotlin의 스코프 함수에 대하여 정리해보았다. 스코프 함수는 특정 객체에 대한 여러가지 동작을 하나의 블록으로 묶어서 가독성을 높일 수 있다는 장점이 있지만, 무분별하게 사용하면 가독성을 떨어트리고 코드가 복잡해져 유지보수가 어려워질 수 있다. 따라서 스코프 함수를 사용할 때는 팀 내부에서 코드 컨벤션을 잘 정해서 사용하는 것이 좋다.
댓글 피드백은 언제나 환영합니다.
참조 링크
'Programming Language > Kotlin & Java' 카테고리의 다른 글
[Kotlin] Kotlin에서의 형변환과 스마트 캐스트(Smart Cast) feat. as, is (0) | 2023.01.16 |
---|---|
[Kotlin] 기본 매개변수(Default parameter)와 명명된 인자(Named Arguments) (0) | 2022.12.27 |
Kotlin 기본 문법 10 : 커스텀 getter와 setter (1) | 2022.12.22 |
Kotlin 기본 문법 9 : Kotlin의 다양한 클래스(Data Class, Enum Class, Sealed Class) (0) | 2022.12.16 |
Kotlin 기본 문법 8 : 정적 변수와 정적 메소드(feat. companion object) (0) | 2022.12.14 |
댓글