추가로 확장 함수와 프로퍼티를 사용해 혼합 언어 프로젝트에서 코틀린의 이점을 모두 살릴 수 있는 방법을 살펴본다.
3.1 코틀린에서 컬렉션 만들기
코틀린에서 컬렉션 만들기
val set = setOf(1, 7, 53)
val list = listOf(1,7, 53)
val map = (1 to "one", 7 to "seven", 53 to "fifty-three")
코틀린은 표준 자바 컬렉션 클래스를 사용한다.
fun main(){
val set = setOf(1, 7, 53)
val list = listOf(1,7, 53)
val map = (1 to "one", 7 to "seven", 53 to "fifty-three")
println(set.javaClass) // class.java.LinkedHashSet
println(list.javaClass) // class.java.Array$ArrayList
println(map.javaClass) // class.java.LinkedHashMap
}
코틀린 컬렉션은 자바 컬렉션과 똑같은 클래스이긴 하지만 코틀린에서는 자바보다 더 많은 기능을 쓸 수 있다.
fun main(){
val strings = listOf("first", "second", "fourteenth")
strings.last()
// fourteenth
println(strings.shuffled())
// [fourteenth, second, first]
val numbers = setOf(1, 14, 2)
println(numbers.sum())
// 17
}
3장에서는 이런 기능이 어떻게 동작하는지 보여주고 자바 클래스에 없는 새로운 메서드들이 어디서 비롯됐는지 살펴본다.
그 전에 먼저 함수 선언에 대한 새로운 개념을 살펴본다.
3.2 함수를 호출하기 쉽게 만들기
주어진 리스트를 주어진 구분자, 접두사, 접미사를 붙여서 반환하는 함수 joinToString 초기 구현
fun <T> joinToString(
collection:Collection<T>,
separator: String,
prefix: String,
postfix: String,
): String {
val result = StringBuilder(prefix)
for((index, element) in collection.withIndex()){
if(index > 0) result.append(separator)
result.append(element)
}
result.append(postfix)
return result.toString()
}
fun main(){
val list = listOf(1,2,3)
println(joinToString(list, "; ", "(", ")"))
// (1; 2; 3)
}
3.2.1 이름 붙인 인자
joinToString 함수 호출 시의 가독성이 좋지 않다.
함수 호출 시 인자로 전달된 각 문자열이 어떤 역할을 하는지 분명하지 않다.
joinToString(list, " ", " ", ".")
코틀린으로 작성한 함수를 호출할 때는 함수에 전달하는 인자 중 일부 또는 전부의 이름을 명시할 수 있다.
일반 호출 문법을 사용할 때는 함수를 선언할 때와 같은 순서로 인자를 지정해야만 하며, 일부를 생략하고 싶을 때는 뒤쪽의 인자들을 생략할 수 있다.
이름 붙은 인자를 사용하는 경우에는, 지정하고 싶은 인자에 이름을 붙여서 순서와 관계없이 지정할 수 있다.
함수의 디폴트 파라미터 값은 함수를 호출하는 쪽이 아니라 함수를 선언하는 쪽에 인코딩로 어떤 클래스 안에 정의된 함수의 기본값을 바꾸고 그 클래스가 포함된 파일을 재컴파일하면 그 함수를 호출하는 코드 중에 값을 지정하지 않은 모든 인자는 자동으로 바뀐 기본값을 적용받는다.
3.2.3 정적인 유틸리티 클래스 없애기: 최상위 함수와 프로퍼티
코틀린에서는 함수를 직접 소스 파일의 최상위 수준, 모든 다른 클래스의 밖에 위치시킬 수 있다.
/* strings 패키지의 join.kt 파일에 정의된 joinToString 함수*/
package strings
fun joinToString(/* ... */):String {/* ... */}
코틀린 파일의 최상위 수준으로 정의된 함수가 자바에서 호출되기 위해 컴파일 되는 형식
/* 자바 */
package strings;
public class JoinKt{ // join.kt라는 파일 이름에 해당하는 클래스
public static String joinToString(/* ... */){/* ... */}
}
/* 자바 */
import strings.StringFunctions;
StringFunctions.joinToString(list, ", ", "", "");
최상위 프로퍼티: 함수와 마찬가지로 프로퍼티도 최상위 수준에 놓일 수 있다.
어떤 데이터를 클래스 밖에 위치시켜야 하는 경우는 흔하지는 않지만, 그래도 가끔 유용할 때가 있다.
어떤 연산을 수행한 횟수를 저장하는 var 프로퍼티를 만들 수 있다.
var opCount = 0
fun performOperation() {
opCount++
//...
}
fun reportOperationCount() {
println("Operation performed $opCount times")
}
이런 프로퍼티의 값은 정적 필드에 저장된다.
최상위 프로퍼티를 활용해 코드에서 상수를 정의할 수 있다.
val UNIX_LINE_SEPERATOR = "\n" // val 선언: 상수
디폴트로 최상위 프로퍼티도 다른 모든 프로퍼티처럼 접근자 메서드를 통해 자바 코드에 노출된다.
이 상수를 자연스럽게 public static final 필드로 노출하고 싶다면 const 변경자를 추가하면 된다.
const val UNIX_LINE_SEPERATOR = "\n"
/* 자바 */
public static final String UNIX_LINE_SEPERATOR = "\n"
3.3 메서드를 다른 클래스에 추가: 확장 함수와 확장 프로퍼티
확장 함수는 어떤 클래스의 멤버 메서드인 것처럼 호출할 수 있지만 그 클래스의 밖에 선언된 선언된 함수이다.
package strings
fun String.lastChar() : Char = this.get(this.length - 1)
// String : 함수가 확장할 클래스의 이름, 수신 객체 타입
// this : 확장 함수 호출 시 호출하는 대상 값, 수신 객체
fun main(){
println("Kotlin".lastChar())
}
자바 클래스로 컴파일된 클래스 파일이 있는 한 그 클래스에 원하는 대로 확장을 추가할 수 있다. 이렇게 되면 기존 자바 API를 재작성하지 않아도 편리한 기능을 사용할 수 있게 된다.
확장 함수에서 일반 메서드 본문에서 this를 사용할 때와 마찬가지로 수신 객체 this도 생략 가능하다.
3.3.1 임포트와 확장 함수
확장 함수를 정의했다고 해도 자동으로 프로젝트 안의 모든 소스코드에서 그 함수를 쓸 수 있지는 않다.
확장 함수를 쓰려면 다른 클래스나 함수와 마찬가지로 해당 함수를 임포트해야만 한다.
이는 이름 충돌을 막기 위함이다.
import strings.lastChar
val c = "Kotlin".lastChar()
import strings.*
val c = "Kotlin".lastChar()
import strings.lastChar as last
val c = "Kotlin".last()
3.3.2 자바에서 확장 함수 호출
내부적으로 확장 함수는 수신 객체를 첫 번째 인자로 받는 정적 메서드다. 따라서 확장 함수를 호출해도 다른 어댑터 객체나 실행 시점 부가 비용이 들지 않는다.
/* 자바 */
char c = StringUtilKt.lastChar("Java"); // static 메서드
3.3.3 확장 함수로 유틸리티 함수 정의
JoinToString() 함수를 확장 함수로 정의하기
fun <T> Collection<T>.joinToString( // Collection<T>에 대한 확장 함수 선언
separator: String,
prefix: String,
postfix: String,
): String {
val result = StringBuilder(prefix)
for((index, element) in this.withIndex()){ // this 키워드는 수신 객체,
if(index > 0) result.append(separator) // 즉 T 타입의 요소로 이루어진
result.append(element) // 컬렉션을 가리킨다.
}
result.append(postfix)
return result.toString()
}
fun main(){
val list: List<Int> = listOf(1,2,3)
println(
list.joinToString(
separator = "; ",
prefix = "(",
postfix = ")"
)
)
// (1; 2; 3)
}
3.3.4 확장 함수는 오버라이드할 수 없다.
코틀린의 메서드 오버라이드도 일반적인 객체지향의 메서드 오버라이드와 마찬가지다.
open class View {
public fun click() = println("View clicked")
}
class Button: View(){
override fun click() = println("Button clicked")
}
fun main(){
val view: View = Button()
view.click() // Button clicked
}
하지만 확장은 이런 식으로 작동하지 않는다.
이름과 파라미터가 완전히 같은 확장 함수를 기반 클래스와 하위 클래스에 대해 정의할 수 있다.
하지만 실제 호출될 함수는 확장 함수를 호출할 때 수신 객체로 지정한 변수의 컴파일 시점의 타입에 의해 결정되지, 실행 시간에 그 변수에 저장된 객체의 타입에 의해 결정되지 않는다.
fun View.showOff() = println("View")
fun Button.showOff() = println("button")
fun main(){
val view: View = Button()
view.showOff() // View 출력
}
확장 함수를 첫 번째 인자가 수신 객체인 정적 자바 메서드로 컴파일한다는 사실을 기억하라
자바도 마찬가지 방식으로 호출할 때 static 함수를 결정한다.
class Demo {
public static void main(String[] args){
View view = new Button()
ExtensionKt.showOff(view) // View 출력
}
}
어떤 클래스를 확장한 함수와 그 클래스의 멤버 함수의 이름과 시그니처가 같다면 확장 함수가 아니라 멤버 함수가 호출된다.
멤버 함수의 우선 순위가 더 높다.
3.3.5 확장 프로퍼티
확장 프로퍼티는 실제로 아무 상태도 가질 수 없기 때문에 커스텀 접근자를 정의해야 한다.
val String.lastChar: Char
get() = get(length - 1)
StringBuilder의 맨 마지막 문자를 변경할 수 있으므로 StringBuilder에 대해 같은 프로퍼티를 정의한다면, 이를 var로 선언할 수 있다.
함수 선언 시 파라미터에 vararg 키워드를 사용하면 호출 시 인자 개수가 달라질 수 있는 함수를 정의할 수 있다.
중위 함수 호출 구문을 사용하면 인자가 하나뿐인 메서드를 간편하게 호출할 수 있다.
구조 분해 선언을 사용하면 복합적인 값을 분해해서 여러 변수에 나눠 담을 수 있다.
3.4.2 가변 인자 함수: 인자의 개수가 달라질 수 있는 함수 정의
파라미터 개수가 달라질 수 있는 함수를 정의하는 법을 알아보자
코틀린에서는 자바처럼 타입 뒤에 ...을 붙이는 대신 파라미터 앞에 키워드 vararg 변경자를 붙인다.
fun <T> listOf(vararg values: T): List<T> {...}
코틀린에서는 배열에 들어있는 원소를 가변 길이 인자로 넘길 때 스프레드 연산자(*)를 이용해 배열을 풀어서 넘겨야 한다.
fun main(args: Array<String>){
val list = listOf("args: ", *args)
println(list)
}
3.4.3 쌍(튜플) 다루기: 중위 호출과 구조 분해 선언
val map = mapOf(1 to "one", 7 to "seven", 53 to "fifty-three")
1.to("one") // to 함수를 일반적인 방식으로 호출
1 to "one" // to 함수를 중위 호출
인자가 하나뿐인 확장 함수에만 중위 호출을 사용할 수 있다.
함수를 중위 호출에 사용하게 허용하고 싶으면 infix 변경자를 함수 선언 앞에 추가
infix fun Any.to(other: Any) = Pair(this, other)
구조 분해 선언
val (number, name) = 1 to "one"
루프에서도 구조 분해 선언을 활용할 수 있다.
for((index, element) in collection.withIndex()){
println("$index : $element")
}
to 함수는 확장 함수다. to를 사용하면 타입과 관계없이 임의의 순서쌍을 만들 수 있다.
이는 to의 수신 객체가 제네릭이라는 뜻이다.
따라서 1 to "one", "one" to 1, list to list.size() 등의 호출이 모두 작동한다.
mapOf 함수의 시그니처를 잘 살펴보자.
fun <K, V> mapOf(vararg values: Pair<K, V>) : Map<K, V>
3.5 문자열과 정규식 다루기
코틀린 문자열은 자바 문자열과 똑같다. 코틀린 코드가 만들어낸 문자열을 아무 자바 메서드에 넘겨도 되며, 자바 코드에서 받은 문자열을 아무 코틀린 표준 라이브러리 함수에 전달해도 문제없다.
특별한 변환도 필요없고 자바 문자열을 감싸는 별도의 래퍼 객체도 생기지 않는다.
코틀린은 다양한 확장 함수를 제공함으로써 표준 자바 문자열을 더 즐겁게 다루게 해준다.
3.5.1 문자열 나누기
코틀린에서는 자바의 split 대신에 여러 가지 다른 조합의 파라미터를 받는 split 확장 함수를 제공함으로써 혼동을 야기하는 메서드를 감춘다.
정규식을 파라미터로 받는 함수는 String이 아닌 Regex 타입의 값을 받는다.
따라서 코틀린에서는 split 함수에 전달하는 값의 타입에 따라 정규식이나 일반 텍스트 중 어느 것으로 문자열을 분리하는지 쉽게 알 수 있다.
fun main(){
println("12.345-6.A".split("\\.|-".toRegex())) // 정규식을 명시적으로 만든다.
}
정규식을 처리하는 API는 표준 자바 라이브러리 API와 비슷하지만 좀 더 코틀린답게 변경됐다.
코틀린에서는 toRegex 확장 함수를 사용해 문자열을 정규식으로 변환한다.
코틀린에서 split 확장 함수를 오버로딩한 버전 중에는 구분 문자열을 하나 이상 인자로 받는 함수가 있다.
fun main(){
println("12.345-6.A".split(".", "-")) // 여러 구분 문자열을 지정한다.
}
3.5.2 정규식과 3중 따옴표로 묶은 문자열
String 확장 함수를 사용해 경로 파싱하기
fun parsePath(path:String){
val directory = path.substringBeforeLast("/")
val fullName = path.substringAfterLast("/")
val fileName = fullName.substringBeforeLast(".")
val extension = fullName.substringAfterLast(".")
println("Dir : $directory, name : $fileName, ext : $extension")
}
fun main(){
parsePath("/Users/yole/kotlin-book/chapter.adoc")
}
코틀린에서는 정규식을 사용하지 않고도 문자열을 쉽게 파싱할 수 있다. 정규식은 강력하기는 하지만 나중에 알아보기 힘든 경우가 많다
정규식이 필요할 때는 코틀린 라이브러리를 사용하면 도움이 된다.
fun pasePathRegex(path:String){
val regex = """(.+)/(.+)\.(.+)""".toRegex()
val matchResult = regex.matchEntire(path)
if(matchResult != null){
val (directory, filename, extension) = matchResult.destructed
// 구조 분해해
println("Dir: $directory, name: $filename, ext: $extension")
}
}
fun main(){
parsePathRegex("/Users/yole/kotlin-book/chapter.adoc")
}
3중 따옴표 문자열에서는 백슬래시를 포함한 어떤 문자도 이스케이프할 필요가 없다.
일반 문자열을 사용해 정규식을 작성하는 경우에는 마침표 기호를 이스케이프하려면 \\. 라고 써야하지만, 3중 따옴표 문자열에서는 \.라고 쓰면 된다.
예제에서 쓴 정규식은 슬래시와 마침표를 기준으로 경로를 세 그룹으로 분리한다.
패턴 . 는 임의의 문자와 매치될 수 있다. 따라서 첫 번째 그룹인 (.+)는 마지막 슬래시를 제외한 모든 슬래시도 들어간다.
비슷한 이유로 두 번째 그룹에도 마지막 마침표 전까지 모든 문자가 들어간다.
세 번째 그룹에는 나머지 모든 문자가 들어간다.
3.5.3 여러줄 3중 따옴표 문자열
3중 따옴표 문자열을 문자열 이스케이프를 피하기 위해서만 사용하지는 않는다.
3중 따옴표 문자열에는 줄 바꿈을 포함해 아무 문자열이나 그대로 들어간다.
3중 따옴표를 쓰면 줄바꿈이 들어있는 텍스트를 쉽게 프로그램에 포함시킬 수 있다.
val kotlinLogo =
"""
| //
| //
|/ \
""".trimIndent()
fun main(){
println(kotlinLogo)
}
파일에서 줄 끝을 표현하기 위해 운영체제마다 서로 다른 문자를 사용하는데, 코틀린에서는 각 운영체제에서 줄 끝을 표현하는 문자(window: CRLF, linux & mac : LF) 모두를 줄 끝으로 취급한다. CRLF, CR, LF를 모두 줄 끝으로 취급한다.
여러 줄 문자열에는 줄바꿈이 들어가지만 줄 바꿈을 \n과 같은 특수 문자를 사용해 넣을 수는 없다.
일반 문자열에서는 공백 문자, 특수 문자에 대한 이스케이프가 필요할 시 해당 공백 문자, 특수문자 앞에 백슬래시()를 추가해줘야 했지만, 3중 따옴표 문자열에서는 따로 이스케이프가 필요없다.
"C:\\\ Users\\\yole\\\kotlin-book"이라고 쓴 윈도우 파일 경로를 3중 따옴표 문자열로 쓰면 """C\\Users\\yole\\kotlin-book"""이다.
3중 따옴표 문자열 안에 문자열 템플릿을 사용할 수도 있으나 3중 따옴표 문자열 안에서는 이스크케이프가 불가하기 때문에 문자열 내용에서 $나 유니코드 이스케이프를 유효하게 사용하기 위해서는 내포식을 사용해야 한다.
""" Hmm ${"\uD83E\uDD14"} """ 처럼 써야만 한다.
3중 따옴표를 이용한 여러 줄 문자열이 요긴한 분야로는 테스트가 있다.
테스트 결과가 여러 줄의 텍스트 출력일 때 유용하다. 복잡하게 이스케이프를 쓰거나 파일에서 텍스트를 불러올 필요가 없다.
3.6 코드 깔끔하게 다듬기: 로컬 함수와 확장
코틀린에서는 함수에서 추출한 함수를 원래의 함수의 내부에 내포시킬 수 있다.
코드 중복을 보여주는 예제
class User(val id: Int, val name: String, val address: String)
fun saveUser(user: User)
{
if(user.name.isEmpty())
{
throw IllegalArgumentException(
"Can't save User ${user.id}: empty Name"
)
}
if(user.address.isEmpty())
{
throw IllegalArgumentException(
"Can't save User ${user.id}: empty Address"
)
}
}
fun main()
{
saveUser(User(1, "", ""))
}
로컬 함수를 사용해 코드 중복 줄이기
class User(val id: Int, val name: String, val address: String)
fun saveUser(user: User)
{
fun validate(user: User, value: String, fieldName: String)
{
if(value.isEmpty())
{
throw IllegalArgumentException(
"Can't save user ${user.id}: $fieldName"
)
}
}
validate(user, user.name, "Name")
validate(user, user.address, "Address")
}
fun main()
{
saveUser(User(1, "", ""))
}
로컬 함수에서 자신이 속한 바깥 함수의 파라미터에 접근할 수 있다.
class User(val id: Int, val name: String, address: String)
fun saveUser(user: User)
{
fun validate(value: String, fieldName: String)
{
if(value.isEmpty())
{
throw IllegalArgumentException(
"Can't save user ${user.id}: $fieldName" // 바깥 함수 파라미터에 // 접근한다.
)
}
}
validate(user.name, "Name")
validate(user.address, "Address")
}
fun main()
{
saveUser(User(1, "", ""))
}
로컬 함수를 수신 객체의 확장 함수로 만들 수도 있다.
class User(val id: Int, val name: String, address: String)
fun User.validateBeforeSave(){ // 로컬 함수를 수신 객체의 확장 함수로
fun validate(value: String, fieldName: String)
{
if(value.isEmpty())
{
throw IllegalArgumentException(
"Can't save user ${id}: $fieldName" // 수신 객체 지정하지 않고도
) // 공개된 멤버 프로퍼티 접근
}
}
validate(name, "Name")
validate(address, "Address")
}
fun saveUser(user: User)
{
user.validateBeforeSave()
}
fun main()
{
saveUser(User(1, "", ""))
}
로컬 함수를 확장하면, 수신 객체를 지정하지 않고도 로컬 함수 내에서 공개된 멤버 프로퍼티나 메서드에 접근할 수 있게 된다.
요약
코틀린은 자체 컬렉션 클래스를 정의하지 않지만, 자바 클래스를 확장해서 더 풍부한 API를 제공한다.
함수 파라미터의 기본값을 정의하면, 오버로딩한 함수를 정의할 필요성이 줄어든다. 이름 붙인 인자를 사용하면, 함수의 인자가 많을 때 함수 호출의 가독성을 더 향상시킬 수 있다.
코틀린 파일에서 클래스 멤버가 아닌 최상위 함수와 프로퍼티를 직접 선언할 수 있다. 이를 통해 코드 구조를 더 유연하게 만들 수 있다.
확장 함수와 프로퍼티를 사용하면, 외부 라이브러리에 정의된 클래스를 포함해 모든 클래스의 API를 그 클래스의 소스코드를 바꿀 필요없이 확장할 수 있다.
중위 호출을 통해 인자가 하나밖에 없는 메서드나 확장 함수를 더 깔끔한 구문으로 호출할 수 있다.
코틀린은 정규식과 일반 문자열을 처리할 때 유용한 다양한 문자열 처리 함수를 제공한다.
자바 문자열로 표현하려면 수많은 이스케이프가 필요한 문자열의 경우 3중 따옴표 문자열을 사용하면 더 깔끔하게 표현할 수 있다.