코틀린 완벽 가이드 - 3장 함수 정의하기
3장 - 함수 정의하기
이 장에서는 함수라는 개념에 대해서 중점적으로 다룬다. 함수의 기본적인 내부 구조와 중요한 요소에 대해서 살펴볼 것이다. 이후 조건문과 여러가지 형태의 반복문 그리고 오류 처리 방법에 대해서 다룬다. 마지막으로는 관련 있는 여러 선언을 패키지로 묶는 방법과 임포트 디렉티브를 사용하는 패키지 간 참조를 살펴볼 것이다.
3.1 함수
코틀린의 함수는 자바의 메서드와 마찬가지로 어떤 입력을 받아서 자신을 호출한 코드 쪽에 출력값을 반환할 수 있는 재사용 가능한 코드 블록이다.
3.1.1 코틀린 함수의 구조
예제 함수를 통해 구조를 파악해보자.
//import directive
//
// fun <function name>([parameter1: type1, parameter2: type2, ...]): <return type> {
// <function body>
// }
import kotlin.math.PI
fun circleArea(radius: Double): Double {
return PI*radius*radius
}
fun main() {
print("Enter radius: ")
val radius = readLine()!!.toDouble()
println("Circle area: ${circleArea(radius)}")
}
주석에는 함수의 기본적인 구조를, 아래는 반지름이 주어졌을 때 원의 넓이를 계산하는 함수를 정의하였다. 구성요소를 자세히 살펴보면 다음과 같다.
- fun 키워드는 컴파일러에게 함수 정의가 나옴을 명시
- 함수 이름(function name)은 변수 이름과 마찬가지로 임의의 식별자로 사용 가능하며, 예로는 circleArea를 사용
- ( )로 둘러싸여 있는 콤마로 분리한 파라미터 목록([parameter1, parameter2, ...)을 작성
파라미터가 없더라도 괄호는 반드시 있어야함 - 반환 타입(return type)은 함수를 호출한 쪽에 돌려줄 반환값의 타입
- 함수 본문(function body)는 {}로 감싼 블록이며, 함수의 구현을 기술
자바와 달리 실질적으로 죽어있는 코드(도달할 수 없는 코드)는 오류가 아니지만 컴파일러 경고과 IDE에서 해당 부분을 강조하기 때문에 쉽게 인지 가능하다.
코틀린에도 자바와 마찬가지로 블록 문이 존재한다. 기본적으로 {}로 감싼 문(statement)의 그룹을 말하며, 블록문 안에는 지역 변수나 지역 함수 정의가 들어갈 수 있다. 이런 선언의 영역은 해당 함수/변수가 선언된 블록 내부로 제한된다. 파라미터 정의는 암시적으로 함수가 호출될 때 자동으로 인자 값으로 초기화되는 지역 변수로 취급된다.
코틀린의 함수 파라미터는 무조건 불변이며 함수 본문에서 파라미터 값을 변경하면 자바와는 다르게 컴파일 오류(Val cannot be reassigned)가 발생한다. 파라미터가 불변이기에 파라미터 앞에 val이나 var을 표시할 수 없다. 이를 통해 대입하는 중 실수할 가능성이 줄어들고, 코드가 깔끔해지고, 이해하기 편한 코드를 만들어낸다.
코틀린은 값에 의한 호출(call-by-value) 의미론을 사용한다. 함수 정의 시 파라미터의 타입은 항상 지정해야한다. 컴파일러는 함수 정의에서 파라미터 타입을 추론하지 못하기 때문이다. 반면 반환타입은 추론이 가능하지만 명시해야한다. 함수의 결과 값이 결정해 외부로 나가는 지점이 여러 곳일 수 있는데, 함수 본문의 모든 반환 지점을 살펴보고 반환 타입을 알아내기 어려울 수 있기 때문이다.
경우에 따라서 반환 타입을 생략할 수 있는데 예외적인 두 가지 경우가 있다.
- 유닛(unit) 타입을 반환하는 경우 - 자바 void에 해당되는 코틀린 타입으로 함수가 의미있는 반환 값을 돌려주지 않음을 의미한다.
- 식이 본문인 함수 - 함수가 단일 식으로만 구현될 수 있다면 return 키워드와 블록 문을 생략하고 사용할 수 있다.
fun circleArea(radius: Double) = PI*radius*radius // 반환값 추론가능
블록이 본문인 함수를 정의할 때 {} 앞에 =를 넣으면 이 블록이 익명 함수를 기술하는 람다로 해석되기 때문에 원하는 결과를 얻을 수 없다. 블록 안에 return 이 있다면 식이 본문인 함수 안에서 return 문이 금지되기 때문에 오류가 발생하기 때문이다.
// 블록을 갖고 있는 함수
fun circleArea(radius: Double) : Double {
return PI * radius * radius
}
// 식이 본문이 함수
fun circleArea(radius: Double) = PI * radius 8 radius
// 람다를 통해 함수를 정의
fun circleArea(radius: Double) = { PI * radius 8 radius }
// Error : return is not allowed here
fun circleArea(radius: Double) = {
return PI * radius 8 radius
}
3.1.2 위치 기반 인자와 이름 붙은 인자
함수 호출 인자는 순서대로 파라미터에 전달된다. 이러한 인자 전달 방식을 위치 기반 인자(positional argument)라고 한다. 이와 더불어 파라미터의 이름을 명시함으로써 인자를 전달하는 방식도 지원하는데 이를 이름 붙은 인자(named argument)라고 한다. 코틀린 1.4 이후부터는 이름 붙은 인자와 위치 기반 인자를 함께 사용할 수 있는데, 이때 이름 붙은 인자는 해당 인자의 위치에 맞게 사용해야 한다.
fun sum(param1: Int, param2: Int): Int {
return param1 + param2
}
fun main() {
// 위치 기반 인자 전달
sum(1,3)
// 이름 붙은 인자 전달
sum(param1 = 3, param2 = 1)
}
3.1.3 오버로딩과 디폴트 값
코틀린 함수도 오버로딩을 지원하며, 이름이 같지만 파라미터의 타입이 다른 여러개의 함수를 작성할 수 있다는 의미이다. 반환 값이 다른 것은 오버로딩을 할 수 있는 조건이 아니다. 실제 호출할 함수를 결정할 때 컴파일러는 오버로딩 해소(overloading resolution) 규칙을 따른다.
- 파라미터의 개수와 타입을 기준으로 호출할 수 있는 모든 함수를 탐색
- 덜 구체적인 함수는 제외. 파라미터 타입을 보고 다른 함수의 파라미터 타입의 상위 타입인 경우 덜 구체적이므로 제외시킴. 이 단계를 반복함
- 후보가 하나로 정해지면 해당 함수를 호출. 둘 이상이면 컴파일 오류 발생
덜 구체적인 함수를 호출하고 싶을 경우 as 타입 캐스팅을 통해 파라미터의 타입을 변경해야한다.
fun mul(a: Int, b: Int) = a*b
fun mul(o: Any, n: Int) = Array(n){o}
fun main() {
mul("0" as Any, 3) // 위의 함수 중 Array(3){"0"} 을 호출
}
코틀린의 경우 경우에 따라서 함수 오버로딩을 사용하지 않고, 디폴트 파라미터를 통해 해결할 수 있다. 가변적인 파라미터를 디폴트 파라미터로 넣고, 지정되지 않은 파라미터의 경우 인자 목록 앞쪽으로 넣어 사용하는 것이다. 만약 디폴트 파라미터 뒤에 지정되지 않은 인자가 있는 경우 이름 붙은 인자를 사용해서 값을 지정할 수 있다.
3.1.4 vararg
인자의 개수가 정해지지 않은 경우(ex. arrayof() 같은 함수) 파라미터 정의 앞에 vararg(Variable number of arguments) 변경자를 붙여 해결할 수 있다.
fun printSorted(vararg items: Int) {
items.sort()
println(items.contentToString())
}
fun change(vararg items: IntArray) {
items[0][0] = 100
}
fun main() {
// 값을 전달한 경우
printSorted(6, 2, 10, 1) // [1,2,6,10]
// 스프레드로 전달한 경우
val number = intArrayOf(6, 2, 10, 1)
printSorted(*numbers) // [1 ,2 ,6 ,10]
printSorted(numbers) // Error : passing IntArray instead of Int
println(number.contentToString()) // [6, 2, 10, 1]
printSorted(6, 1, *intArrayOf(3,8), 2) // 6,1,3,8,2가 전달되고, [1,2,3,6,8]이 반환됨
// 참조 변수를 전달한 경우
val a = intArrayOf(1,2,3)
val b = intArrayOf(4,5,6)
change(a,b)
println(a.contentToString()) // [100,2,3]
println(b.contentToString()) // [4,5,6]
}
printSorted 함수 내부에서 items는 IntArray 타입으로 사용된다. 또한 스프레드(spread) 연산자인 *를 사용하면 배열을 가변 인자 대신 넘길 수 있다. 스프레드는 배열을 복사하기 때문에 파라미터 배열의 내용을 바꿔도 원본 원소에는 영향을 미치지 않는다.
이때의 복사는 얕은 복사가 이루어진다. 배열 내부에 참조가 들어있는 경우에는참조가 복사되기 때문에 참조가 가리키는 데이터가 호출하느 족과 함수
둘 이상을 vararg 파라미터로 선언하는 것은 금지되며, 하나의 vararg 파라미터에 콤마로 분리된 여러 인자와 스프레드를 섞에서 전달하는 것은 괜찮다. 호출은 원래의 순서가 유지되는 단일 배열로 합쳐진다. 디폴트와 마찬가지로 vararg는 맨 마지막에 위치시키는 것이 좋은 코딩 스타일이다. 디폴트 값과 vararg를 같이 쓰게 된다면 vararg를 앞에 쓰는 것이 좋다. 디폴트 이후에 나오는 값에 대해서 이름 붙은 인자를 통해 스프레드를 전달해야되고, 이는 vararg를 도입한 목적에 맞지 않기 때문이다.
fun printSortedDefaultFirst(prefix: String = "", vararg items: Int) {}
fun printSortedVarargFirst(vararg items: Int, prefix: String = "") {}
fun main() {
//
printSortedDefaultFirst(6,2,10,1) // Error: 6 is taken as value of prefix
printSortedDefaultFirst(items = *intArrayOf(6,2,10,1) // 정상이지만, 배열을 만들어서 다시 풀어 넣어주는 행위임 -> vararg 사용 목적에 맞지 않음
printSortedVarargFirst(6,2,10,1) // Error: type mismatch: inferred type is String but Int was wxpected
printSortedVarargFirst(6,2,10,1, prefix="!")
}
3.1.5 함수의 영역과 가시성
코틀린 함수는 정의된 위치에 따라 세 가지로 구분할 수 있다.
- 파일에 직접 선언된 최상위 함수 ← 이번장에서 다룰 내용
- 어떤 타입 내부에 선언된 멤버 함수
- 다른 함수 안에 선언된 지역 함수 ← 이번장에서 다룰 내용
지금까지 main()과 같은 최상위 함수만 선언하여 사용했다. 기본값으로 최상위 함수는 public 함수이다. 경우에 따라 프로젝트의 나머지 부분으로부터 구현 상세 내용을 숨겨서 보호하고 싶다면 가시성 변경자(visibility modifier)를 통해 함수 정의 앞에 private 나 internal 키워드를 붙여 제한을 둘 수 있다.
- public : 모든 파일에서 해당 함수 사용 가능
- private : 함수가 정의된 파일 안에서만 해당 함수를 사용 가능
- internal : 함수가 적용된 모듈 내부에서만 함수를 사용 가능 (모듈 = 함께 컴파일된 파일 전부)
코틀린은 지역 변수처럼 함수 내부에 지역 함수를 정의할 수 도 있다. 함수를 감싸는 블록으로 지역함수는 한정된다. 지역 변수와 지역함수는 가시성 변경자를 붙일 수 없다.
3.2 패키지와 임포트
코틀린 패키지는 관련 있는 선언을묶는 방법이다. 패키지는 이름이 있고, 다른 패키지를 포함할 수 있다. 자바와 비슷하지만 코틀린만의 특징도 있으니 살펴보자.
3.2.1 패키지와 디렉터리 구조
코틀린 파일 맨 앞에 패키지 이름을 지정하면 파일에 있는 모든 최상위 선언을 지정한 패키지 내부에 넣을 수 있다. 지정하지 않으면 컴파일러는 파일이 디폴트 최상위 패키지(이름 없는 패키지)에 속한다고 가정한다.
패키지 디렉티브는 package 키워드로 시작하고, 점(.)으로 구별된 식별자들로 이뤄진 패키지 전체 이름이 뒤에 온다. 같은 패키지 안에서는 간단한 이름을 사용해 패키지 내에 있는 다른 정의를 참조할 수 있다.(main.kt와 func.kt 관계) 지금까지 살펴본 예제에서는 선언들이 암시적으로 루트 패키지 안에 속해 있다고 가정했다.
선언이 다른 패키지에 들어있다면 전체 이름을 사용해 선언을 참조할 수 있다. 전체이름은 기본적으로 패키지의 전체 이름 + 함수명으로 구성된다.(main.kt와 util.kt 관계)
다른 패키지에 있는 함수를 재사용하기 위해 코드가 너무 길어지기 때문에 코틀린은 import 디렉티브를 사용해 간단하게 접근할 수 있다. 코틀린은 소스 파일 구조와 패키지 구조가 일치하지 않아도 된다. 자바는 불일치할 경우 컴파일 오류가 발생한다. 하지만 프로젝트의 가독성을 위해 일치하는 것을 권장한다.
3.2.2 임포트 디렉티브 사용하기
임포트 디렉티브가 클래스나 함수 등의 최상위 선언만 임포트할 수 있는 것은 아니다. 클래스 안에 내포된 클래스나 이넘 상수 등도 임포트 가능하다. 코틀린에서는 타입 멤버를 임포트하는 별도의 import static 디렉티브가 없다. 모든 선언은 일반적인 import 디렉티브 구문을 사용해 임포트할 수 있다. 서로 다른 파일에서 동일한 이름을 가진 함수를 임포트하려면 import 별명(alias)라는 방법을 사용한다. 또한 어떤 영역에 속한 모든 선언을 한꺼번에 임포트하기 위해서는 *를 붙이면 된다.
import app.util.foo.readInt as fooReadInt
import app.util.bar.readInt as barReadInt
import kotlin.math.*
fun main() {
val n = fooReadInt()
val m = barReadInt()
}
3.3 조건문
조건의 값에 따라 동작을 수행하는 것을 의미한다. 코틀린에서는 if, when 조건문이 있다.
3.3.1 if 문으로 선택하기
if 문을 사용하면 Boolean 식의 결과에 따라 실행의 흐름이 선택된다. 조건은 항상 Boolean 식이여야되며, 자바와 다르게 코틀린 if를 식으로 사용할 수 있다는 것이 다르다. 블록의 맨 끝에 있는 식의 값이 블록 전체의 값이 된다. if 문을 식으로 사용할 때는 else 도 반드시 있어야한다.
// using by function
fun max(a: Int, b: Int): Int {
if (a>b) return a
else return b
}
// using by expression
fun max(a: Int, b: Int) = if (a>b) a else b
3.3.2 범위(range), 진행(progression), 연산(in, !in)
코틀린은 순서가 정해지 값 사이의 수열을 표현하는 몇가지 타입을 제공한다.
// 1. .. 연산자 (시작 값과 끝 값이 범위에 포함, 시작 값 > 끝 값인 경우 빈 값)
val chars = 'a'..'h' // 'a' 부터 'h'까지 모든 문자
val twoDigits = 10..99 // 10부터 99까지의 모든 수
val zero2One = 0.0..1.0 // 0부터 1까지 모든 부동소수점 수
// .. 연산자 응용 - 값 포함 유무 확인 (in)
100 in 10..99 // false
100 !in 10..99 // true
"def" in "abc".."xyz" // true
"zzz" in "abc".."xyz" // false
// 2. until (시작 값은 포함하지만 끝 값은 포함하지 않음, 시작 값 >= 끝 값인 경우 빈 값)
val twoDigits = 10..100
// 3. 진행 - 정해진 간격(Step)만큼 떨어진 정수나 Char 값들로 이뤄진 시퀀스를 의미
10 downTo 1 // 10 ~ 1 까지
15 downTo 8 step 2 // 15, 13, 11, 9
연산자 우선 순위는 범위(..) > 중위(이름 붙은 중위 연산자들) > 원소 검사(in !in) > 비교 연산자 순서이다. uintil, downTo, step은 다른 이름 붙은 중위 연산자와 우선 순위가 같다.
3.3.3 when 문과 여럿 중에 하나 선택하기
if는 조건문의 참/거짓 중에 하나를 선택하지만, when은 자바의 switch 문과 같이 여러가지 중에 하나를 선택할 수 있다.
fun hexDigit(n: Int): Char {
when {
n in 0..9 -> return '0' + n
n in 10..15 -> returjn 'A' + n - 10
else -> return '?'
}
}
when 키워드 다음에는 블록이 온다. 블록 안에는 조건 -> 문 형태로 된 여러 개의 조건들이 있다. 순서대로 검사하며 맨 처음으로 참으로 평가되는 조건을 찾고 실행한다. 만약 모든 조건 문이 거짓이라면 else 부분을 실행한다. (else가 없다면 when 문이 끝남)
코틀린의 when 문에서는 범위 검사를 포함한 임의의 조건을 검사할 수 있지만, 자바의 switch 문은 식의 여러 값 중 상수 값에 대한 검사할 수 있다는 것이다. 추가로 swith 문은 폴스루(조건에 대응하는 문을 실행하고 명시적으로 break를 만날 때까지 실행) 방식으로 수행한다는 것이다.
비교 대상이 한가지 인 경우 아래과 같이 리팩토링을 할 수 있다.
fun hexDigit(n: Int): Char {
when(n) {
in 0..9 -> return '0' + n
in 10..15 -> returjn 'A' + n - 10
else -> return '?'
}
}
3.4 루프
코틀린은 주어진 데이터에 대해 수행하거나 주어진 조건이 만족될 때까지 수행하는 세 가지 제어 구조를 제공한다.
3.4.1 while과 do-while fnvm
while 혹은 do-while 문의 조건이 거짓일 때 해당 블록을 빠져나오는 것이다. (Java와 유사하여 설명 생략)
3.4.2 for 루프와 이터러블
자바의 for each 문과 유사하며, 컨테이너로 사용할 객체가 원소 추출을 위한 iterator() 함수를 지원하면 모두 사용 가능하다.
3.4.3 루프 제어 흐름 변경하기: break와 continue
- break : 즉시 루프를 종료시키고, 실행 흐름이 루프 바로 다음 문으로 이동게 만듬
- continue : 현재 루프 이터레이션을 마치고 조건 검사로 바로 진행하게 만듬
3.4.4 내포된 루프와 레이블
루프를 사용하는 경우 break/continue 식은 가장 안쪽에 내포된 루프에만 적용된다. 경우에 따라 더 밖에 있는 루프의 제어 흐름을 변경하고 싶을 때가 있는데 이를 위해 코틀린은 다른 문법의 레이블 기능을 제공한다.
바깥쪽 루프에 레이블을 붙이고, break와 continue에 해당 레이블 이름을 같이 명시하여 사용한다.
fun indexOf(subarray: IntArray, array: IntArray): Int {
outerLoop@ for (i in array.indices) {
for (j in subarray.indices) {
if (subarray[j] != array[i + j]) continue@outerLoop
}
return i
}
3.4.5 꼬리 재귀 함수
코틀린은 꼬리 재귀(tail recursive) 함수에 대한 최적화 컴파일을 지원한다. 일반적으로 비재귀 버전과 비교하면 성능 차원에서 약간의 부가 비용이 발생하고 스택 오버플로우가 발생할 가능성이 있다. 하지만 코틀린에서는 함수에 tailrec을 붙이면 컴파일러가 재귀 함수를 비재귀적인 코드로 자동 변환해준다.
변환을 적용하려면 함수가 재귀 호출 다음에 아무 동작도 수행하지 않은 꼬리 재귀함수여야하며, 일반 재귀함수라면 컴파일러는 경고 표시를 하고 일반적인 재귀 함수로 컴파일하며, 재귀함수가 아닌 경우에도 동일하게 처리한다.
3.5 예외 처리
코틀린의 예외 처리는 자바의 접근 방법과 아주 비슷하다. 예외가 발생한 경우에는 함수를 호출한 쪽에서 예외를 잡아내거나, 함수 호출 스택의 위로 예외가 전달될 수 있다.
3.5.1 예외 던지기
오류 조건을 신호로 보내려면 throw 식에 예외 객체를 사용해야 한다. 문자열이 잘못된 경우 폴백 값을 돌려주는 대신 오류를 발생시키도록 함수를 작성해보면 다음과 같다.
fun parseIntNumber(s: String): Int {
var num = 0
if (s.length !in 1..31) throw NumberFormatException("Not a number: $s")
for (c in s) {
if (c !in '0'..'1') throw NubmerFormatException("Not a number: $s")
num = num*2 + (c-'0')
}
return num
}
자바와 달리 코틀린에서는 예외 인스턴스를 생성할 때 new 키워드를 사용하지 않는다. 예외 처리 단계는 아래와 같다.
- 예외를 잡아내는 핸들러를 찾는다. 예외와 일치하는 예외 핸들러가 있다면 예외 핸들러가 예외를 처리한다.
- 현재 함수 내부에서 핸들러를 찾을 수 없다면 함수 실행이 종료되고 함수가 스택에서 제거된다.
- 호출한 쪽의 문맥 안에서 예외 핸들러 검색을 수행한다. 이런 경우 예외를 호출자에게 전파했다고 말한다.
- 프로그램 진입점에 이를 때까지 예외를 잡아내지 못하면 현재 스레드가 종료된다.
3.5.2. try 문으로 예외 처리하기
기본적으로 자바와 동일한 문법의 trty 문을 사용한다. 예외가 발생할 수 있는 코드를 try 블록으로 감싼다. try 블록 아래는 적절한 타입의 예외를 잡아내는 catch 블록이 있어야한다. 자바에서는 catch (FooException || BarException e) {} 같은 구문을 지원하지만 아직 코틀린에서는 1개의 catch문에 여러개의 예외를 처리하지 못한다.
finally 블록은 try 블록 앞이나 내부에서 할당한 자원을 해제할 때 유용하게 사용된다.