스코프란?
번역하면 '범위'입니다. 프로그래밍에서 범위를 표시하고 있는 것의 대표적인 예가 중괄호입니다. 그렇다면 중괄호 안에서 범위에 제한되는 것은 어떤 게 있을까? 대표적으로 변수가 있습니다. 이를 종합해보면 컴퓨터 프로그래밍에선 스코프를 변수 영역이라고 부르는 것을 알 수 있습니다. 그런데 range나 extent, area 등과 같은 범위를 나타내는 영어는 많은데 왜 하필 scope일까? 스코프의 어원은 그리스 어로 '겨누는 곳' 따라서 변수를 겨누는 범위를 나타내야 하므로 scope를 사용했다고 볼 수 있습니다.
스코프(변수 영역)의 종류
전역
소스 코드 상의 모든 곳에서 사용할 수 있는 변수 영역입니다. 예시로 전역변수가 있습니다.
함수 영역
함수 내에서만 유효한 변수영역입니다. 대부분의 프로그래밍 언어가 이 영역을 제공합니다.
모듈 영역
모듈라나 파이썬 같이 모듈을 지원하는 프로그래밍 언어에서는 모듈 단위 변수를 선언할 수 있습니다. 해당 변수는 모듈 안에서만 접근할 수 있으며, 모듈 밖에서는 볼 수 없습니다.
파일 영역
C와 C++ 및 그 외 유사 언어에서 지원하는 개념입니다. 소스파일 최상위에서 선언된 변수나 함수는 해당 파일 전체에서 접근할 수 있고 다른 파일에선 접근할 수 없습니다.
블록 영역
알골과 C에 영향을 받은 많은 언어들은 블록단위 변수를 지원합니다. 블록단위 변수는 중괄호로 이루어진 블록 안에서만 사용합니다.
fun main(args: Array<String>) {
run {
val i = 0
println(i)
}
println(i)
}
- 결과 -
e: C:\work\untitled\src\main\kotlin\Main.kt: (8, 13): Unresolved reference: i
run블록 안에서 선언된 i는 run블록 안에서만 사용가능합니다. 따라서 외부에서 i를 호출한 print문은 예외를 발생시킵니다.
표현식 영역
대부분의 함수형 프로그래밍에선 let문을 지원합니다. 예를 들어 ML에서 let val x = f() in x * x end
와 같은 표현식에서, 변수 x는 이 표현식 안에서만 유효합니다. 이와 같은 표현식은 f()를 두 번 호출하는 낭비를 막기 위해 쓰일 수 있습니다.
코틀린 스코프 함수?
스코프에 대해선 어느 정도 이해가 됐는데 그렇다면 스코프 함수란 뭘까요? kotlinlang 사이트에선 다음과 같이 정의합니다.
The Kotlin standard library contains several functions whose sole purpose is to execute a block of code within the context of an object. When you call such a function on an object with a lambda expression provided, it forms a temporary scope. In this scope, you can access the object without its name. Such functions are called scope functions. There are five of them: let, run, with, apply, and also.
Basically, these functions do the same: execute a block of code on an object. What's different is how this object becomes available inside the block and what is the result of the whole expression.
번역하면
Kotlin 표준 라이브러리에는 문맥 객체 내에서 코드 블록을 실행하는 것이 유일한 목적인 여러 함수가 포함되어 있습니다. 람다 표현식이 제공된 객체에서 이러한 함수를 호출하면 임시 범위가 형성됩니다. 이 범위에서는 이름 없이 개체에 액세스 할 수 있습니다. 이러한 함수를 범위 함수라고 합니다. let, run, with, apply, also의 다섯 가지가 있습니다. 기본적으로 이러한 기능은 동일한 작업을 수행합니다. 즉, 개체에서 코드 블록을 실행합니다. 다른 점은 이 개체가 블록 내에서 사용 가능한 방식과 전체 표현식의 결과입니다.
간단히 요약하면 객체 내에서 코드블록을 실행하는 것이 유일한 목적인 함수입니다.
특징으로 람다 표현식이 제공된 객체에서 스코프 함수를 호출하면 임시 영역(중괄호 블록)이 생깁니다. 임시 영역에서는 객체의 이름 없이(it, this 포함) 객체 내부의 함수와 속성에 접근할 수 있습니다.
종류
간단하게 표로 보면 각 함수의 특징은 다음과 같습니다.
Function | Object reference | Return value | Is extension function |
let | it | Lambda result | Yes |
run | this | Lambda result | Yes |
run | - | Lambda result | No: called without the context object |
with | this | Lambda result | No: takes the context object as an argument. |
apply | this | Context object | Yes |
also | it | Context object | Yes |
let
객체는 스코프 내에서 it으로 참조 가능하며, 람다의 결과를 리턴합니다. 따라서 콜 체인의 결과에 하나이상의 함수를 호출하는 데 사용될 수 있습니다. 예시로 리스트에서 두 가지 연산의 결과를 출력한다고 가정해봅시다.
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")
val resultList = 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")
val resultList = numbers.map{ it.length }.filter{ it > 3 }.let(::println)
}
-결과-
[5, 4, 4]
let의 또 다른 특징으로 코드 블록 안에서 호출되는 it(객체 콘텍스트)는 nullable하지 않습니다. 따라서 null이 아닌 값으로만 코드 블록을 실행하는 데 자주 사용됩니다. null이 아닌 객체에 대해 작업을 수행하려면 안전 호출 연산자 (?.)로 let을 호출하면 됩니다.
fun main() {
val str: String? = "hello"
val length = str?.let {
println("let() call on $it")
it.length
}
}
-결과-
let() call on hello
fun main() {
val str: String? = null
val length = str?.let {
println("let() call on $it")
it.length
}
}
-결과-
가독성을 위해 로컬 변수를 소개하는 방식을 바꿀 수 있습니다. 기본 it 대신 람다 연산자를 사용해서 바꿀 수 있습니다.
fun main() {
val str: String? = "Hello"
val length = str?.let { str ->
println("let() call on $str")
str.length
}
}
with
확장 함수가 아니라 객체를 패러미터로 받습니다. 하지만 람다 안에선 this(생략 가능)로 참조 가능합니다. 리턴 값은 람다의 결과를 리턴합니다.
람다 결과를 제공할 필요 없이 객체를 호출하고 싶다면 with 사용을 추천드립니다. with의 의미는 “with this object, do the following.(이 객체로, 다음을 수행해라)"라고 볼 수 있습니다.
fun main() {
val numbers = mutableListOf("one", "two", "three")
with(numbers){
println("'with' is called with argument $this \n" +
"It contains $size elements")
}
}
-결과-
'with' is called with argument [one, two, three]
It contains 3 elements
또 다른 사용 방법으로 속성이나 함수가 값을 계산하는 데 사용되는 객체를 제공하는 것입니다.
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과 기능이 같지만 let처럼 호출됩니다. run은 람다에 할당과 리턴 값 계산이 모두 포함돼있을 때 유용합니다.
import javax.sound.sampled.Port
fun main() {
val service = Test()
val result = service.run {
initializeValue = 100
playFunction("test is ${initializeValue}% Successful")
}
val letResult = service.let {
it.initializeValue = 80
it.playFunction("test is ${it.initializeValue}% Successful")
}
println("-----run-----")
println(result)
println("-----let-----")
println(letResult)
}
class Test(var initializeValue: Int = 0){
fun playFunction(str: String): String{
return "$str!!!!!!"
}
}
-결과-
-----run-----
test is 100% Successful!!!!!!
-----let-----
test is 80% Successful!!!!!!
run을 수신 객체에 호출하는 것 외에, run은 단독 사용 가능합니다. 변수를 선언해서 객체를 생성하는 등, 다양한 명령문 블록을 실행할 수 있습니다.
fun main() {
val hexNumberRegex = run {
val digits = "0-9"
val hexDigits = "A-Fa-f"
val sign = "+-"
Regex("[$sign]?[$digits$hexDigits]+") //return
}
for (match in hexNumberRegex.findAll("+123 -FFFF !%*& 88 XYZ")) {
println(match.value)
}
}
-결과-
+123
-FFFF
88
apply
객체는 스코프 안에서 this(생략 가능)으로 참조 가능하며 객체 자체를 리턴합니다.
리턴 값 없이 객체 멤버에 대한 연산이 주 목적인 코드 블록이 필요하다면 apply를 사용하면 됩니다. 일반적으로 apply는 객체 구성에 사용됩니다. “apply the following assignments to the object.(다음 할당을 객체에 적용한다)”라고 볼 수 있습니다.
fun main() {
val adam = Person("Adam").apply{
age = 32
city = "Ulsan"
}
println(adam)
}
class Person(var name: String = "", var age: Int = 0, var city: String = ""){
override fun toString(): String {
return "[name = $name, age = $age, city = $city]"
}
}
-결과-
[name = Adam, age = 32, city = Ulsan]
also
스코프 내에서 객체를 it으로 참조할 수 있으며 객체 자체를 리턴합니다. also는 객체를 인수로 사용하는 작업을 수행하는데 좋습니다. 또한 속성 및 함수가 아닌 객체에 대한 참조가 필요한 작업이나 외부 범위에서 이 참조를 숨기고 싶지 않은 경우에도 사용합니다. also 코드를 보면 "and also do the following with the object.(또한 다음 작업을 해라)"라고 볼 수 있습니다.
fun main() {
val numbers = mutableListOf("one", "two", "three")
numbers
.also { println("The last elements before adding new one: $it") }
.add("four")
println("외부 범위")
println(numbers)
}
-결과-
The last elements before adding new one: [one, two, three]
외부 범위
[one, two, three, four]
위 코드를 보면 The last elements... 문구는 한 번만 출력된 것을 볼 수 있습니다.
takeIf, takeUnless
추가적인 스코프 함수로 takeIf와 takeUnless 가 있습니다. 이 함수들은 객체 상태 검사를 할 수 있습니다.
takeIf는 람다 내부의 조건문이 참일 때 객체를 리턴하고, 거짓일 때 null을 리턴합니다
반대로 takeUnless는 조건문이 참일 때 null을 리턴하고, 거짓일 때 객체를 리턴합니다.
기능을 단적으로 보여주는 예시입니다.
fun main() {
val number = 2
val evenOrNull = number.takeIf { it == 2 }
val oddOrNull = number.takeUnless { it == 2 }
println("even: $evenOrNull, odd: $oddOrNull")
}
-결과-
even: 2, odd: null
랜덤 함수를 쓰면 아래와 같습니다.
import kotlin.random.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")
}
-결과-
even: 30, odd: null
중요한 점은 takeIf, takeUnless를 쓰고 난 이후엔안전 호출 연산자 (?.)를 사용해주는 것을 잊지 않아야 합니다. 리턴 값이 nullable 하기 때문입니다.
fun main() {
val str = "Hello"
val caps = str.takeIf { it.isNotEmpty() }.uppercase()
println(caps)
}
-에러-
Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?
fun main() {
val str = "Hello"
val caps = str.takeIf { it.isNotEmpty() }?.uppercase()
println(caps)
}
-결과-
HELLO
takeIf, takeUnless는 스코프 함수와 함께 쓰면 더 유용합니다. 특히 let과 함께쓰면 더 좋습니다. let은 코드 블록 내에서 null이 아닌 경우에만 실행하므로 null허용을 막을 수 있습니다. 또한 if문 하나를 안 써도 되는 소소한 장점도 있습니다.
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")
-결과-
The substring 11 is found in 010000011.
Its start position is 7.
원래 코드입니다. if문을 통해 index가 0 이상인 경우만 출력하고 있습니다.
fun main() {
displaySubstringPosition("010000011", "11")
displaySubstringPosition("010000011", "12")
}
fun displaySubstringPosition(input: String, sub: String){
input.indexOf(sub).let {
println("The substring $sub is found in $input.")
println("Its start position is $it.")
}
}
-결과-
The substring 11 is found in 010000011.
Its start position is 7.
The substring 12 is found in 010000011.
Its start position is -1.
takeIf를 사용하지 않고 let만 사용한 결과입니다. indexOf는 패러미터로 찾을 수 없는 경우 -1을 리턴합니다. 따라서 쓸모없는 결과도 출력됩니다. 여기에. takeIf { it >= 0 }?
써준다면 원하는 결과만 볼 수 있습니다.
fun main() {
displaySubstringPosition("010000011", "11")
displaySubstringPosition("010000011", "12")
}
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.")
}
}
-결과-
The substring 11 is found in 010000011.
Its start position is 7.
마치며

코틀린에서 많이 사용되는 스코프 함수에 대해 알아봤습니다. 종류가 많아 어떤 걸 사용해야 할지 헷갈릴 수 있지만 사용목적과 취향에 따라 적절하게 사용하면 됩니다. 저는 with 빼고 다 사용합니다.
참고
변수 영역 - 위키백과, 우리 모두의 백과사전
위키백과, 우리 모두의 백과사전. 컴퓨터 프로그래밍에서 변수 영역은 변수가 유효성을 갖는 영역을 가리킨다. 프로그램은 영역을 벗어난 변수를 가리킬 수 없다. 변수 영역을 지정하는 규칙은
ko.wikipedia.org
Scope functions | Kotlin
kotlinlang.org
'코틀린' 카테고리의 다른 글
기본타입 (1) | 2023.01.30 |
---|---|
기초구문, 이디엄(관용어) (0) | 2023.01.30 |
16. 코루틴 기본 (0) | 2022.06.13 |
15. 인라인 함수 (0) | 2022.06.13 |
14. 고차함수와 람다 (0) | 2022.06.12 |