바쁜 자바 프로그래머를 위한 코틀린 가이드

바쁜 자바 프로그래머를 위한 코틀린 가이드

그 동안 칩거하면서 일했던 것이 아니라면, 요즘에는 코틀린이 인기를 끌고 있다는 걸 알 것이다. 2017년에 구글이 코틀린을 안드로이드 공식 언어라고 발표한 이후로 코틀린의 인기는 가파르게 상승하고 있다.

코틀린이 간결하고 시작하기에 부담이 없기는 하지만 이제 막 시작하는 개발자들에게는 꽤 시간이 필요할 것이다. 하지만 자바 개발자들은 공식 문서와 유명한 코틀린 튜토리얼 Kotlin In Action을 통해서 매우 빠르게 배울 수 있다.

위에 언급한 사이트들에서 많은 부분을 가져오긴 했지만, 자바 개발자들을 위해서 자바와 다른 점들과 새로운 점들에 집중해서 이 문서를 작성했다. 또한, 긴 해설을 하는 것보다는 소스코드에 주석을 다는 것으로 간결하게 표현하려고 했다.

이 짧은 가이드가 코틀린을 배우는걸 쉽게 도와주고, 적어도 코틀린에 대한 시작점이 되기를 바란다.

패키지

자바와는 다르게 코틀린은 당신의 소스 파일들에 대한 레이아웃이나 네이밍에 대한 어떠한 제한 사항도 없다. 여러개의 클래스들을 하나의 파일에 넣을 수 있고, 어떠한 디렉토리 구조도 당신이 원하는대로 선택할 수 있다.

작고 관련된 클래스들을 하나의 파일에 넣는걸 고민할 필요는 없기는 하지만 전체적으로 자바의 디렉토리 구조를 따르는게 좋기는 하다.

변수들

코틀린에는 변수를 선언하는 두가지 키워드가 있다.

  • val – 변하지 않는 값. val 변수는 초기화한 후 다시 재할당할 수 없다. 자바의 final 변수와 똑같다.
  • var – 변하는 값. 이 변수의 값은 변경이 가능하다.
val a = 77  // 값이 변하지 않는 변수 선언 
var b = 77  // 값이 변하는 변수 선언

코틀린에서는 세미콜론을 붙여도 되고 안 붙여도 된다.

코틀린의 변수 선언은 위에 두가지 중에 하나로 시작한다. 그리고 변수 이름이 뒤따른다. 변수의 타입은 이름 뒤에 콜론을 붙여서 표시할 수도 있다. 자바와 같이, 코틀린은 정적 타입 언어다. 하지만 컴파일러가 문맥에 의해서 변수의 타입을 결정할 수 있다. 이것을 타입 추론이라고 부른다.

val hello: String = "Hello"  // 타입을 명백히 표현
val hello = "Hello"          // 타입 추론으로 String
val number_i = 77   // Int형으로 추론
val number_l = 77L  // Long형으로 추론
val number_d = 77.0 // Double형으로 추론
val number_f = 77f  // Float형으로 추론

코틀린은 최상위 레벨 변수를 지원한다. 꼭 클래스 안에서 선언할 필요가 없다.

Equal(==) 연산자

자바와 다르게, 코틀린의 == 연산자는 내부적으로 equals를 호출해서 두개의 오브젝트를 비교한다. 참조하는 값을 비교하려면, === 연산자를 사용할 수 있다.

val a = Pair("aaa", 77)
val b = Pair("aaa", 77)
println(a == b)  // true 값 출력
println(a === b) // false 값 출력

런타임시에 원시 타입의 값들에게는, === 연산자는 == 와 동일하게 작동한다.

Null 안전성

코틀린과 자바의 명백한 차이점 중 하나는 코틀린의 nullable 타입에 대한 지원이다. 타입 이름 뒤에 물음표 마크를 붙이면 변수가 null값을 가질 수 있게 된다.

var nickname: String? = null
var name: String = "June"

name = null
^ 에러: non-null 타입 스트링 변수에는 null 값을 대입할 수 없다
nickname = name

name = nickname
^ 에러: 두개의 변수의 타입이 다르다.

물음표가 없는 타입은 nullable 변수의 값을 저장할 수 없다.

코틀린은 nullable 타입들을 처리할 수 있는 유용한 툴을 제공한다. 안전 호출 연산자(?)는 자주 사용하게 될 것이다.

// nickname이 null이면 null을 리턴한다. null이 아니면 nickname의 값을 대문자값 바꿔서 리턴한다.
val nickname_uppercase = nickname?.toUpperCase()

위에 안전 호출 연산자는 엘비스 연산자와 같이 자주 사용된다.

// nickname에 값이 있다면 값을 리턴, nickname이 null이면 "unknown"을 리턴한다
val safe = nickname ?: "unknown"

// nickname이 null이면 0을 리턴한다.
val length = nickname?.length ?: 0

not-null 보장 연산자(!!)를 사용한 변수값이 null인 경우 NPE(null pointer exception)를 발생시킬 수 있다.

// nickname이 null이 아니라고 보장했기 때문에, nickname값이 null이라면 null pointer exception을 발생시킨다.
val length = nickname!!.length

스트링 템플릿

코틀린은 스트링 구문 내에 변수를 넣어 참조하는 것도 가능하게 한다.

val str_add = "World"
val greeting = "Hello $str_add" // 값은 "Hello World"

스트링 구문 내 참조 구문들은 정적으로 체크된다. 그리고 변수 이름에 제약이 없고, 복잡한 구문까지도 사용 가능하다.

println("Hello ${if (array.size > 0) array[0] else "No array"}!")

원시 타입들

자바와는 다르게, 코틀린은 원시 타입과 원시 타입을 감싸고 있는(wrapper) 타입을 구분한다. 아래는 자바 원시 타입에 해당하는 코틀린 타입을 나타낸다.

  • Integer 타입 – Byte, Short, Int, Long
  • Floating-Point 타입 – Float, Double
  • Character 타입 – Char
  • Boolean 타입 – Boolean

자바 원시 타입과 명백하게 동일한 것은 없기 때문에 효율성에 대해서 걱정이 될 수도 있다. 하지만 코틀린 컴파일러는 가장 효율적인 타입을 알아서 결정하기 때문에 걱정할 필요는 없다. Collection에서 사용하거나, null이 허용되는 것이 아니라면, 코틀린의 Double 타입은 자바 원시 타입 double로 컴파일 될 것이다.

코틀린과 자바의 중요한 다른 점 하나는 숫자 변환이다. 코틀린은 숫자를 하나의 타입에서 다른 타입으로 자동적으로 변환시키지 않는다. 변환은 명확히 표시되어야 한다.

val a = 77
val b: Long = a
^ 에러: Integer 값을 Long값에 저장하려 하므로 타입이 맞지 않음.

배열

자바와는 다르게 코틀린에서 배열은 그냥 클래스이다. 배열 객체는 arrayOf, arrayOfNulls, emptyArray 표준 라이브러리 메소드를 이용해서 생성된다.

val integersArray: Array<Int> = arrayOf(75, 76, 77)
val stringsArray: Array<String?> = arrayOfNulls(77)
val emptyArray: Array<Double> = emptyArray()

Array 클래스의 타입 값은 항상 객체의 타입이 된다. 즉, Array<Int>는 java.lang.Integer[]로 컴파일 될 것이다. 원시 타입들의 배열을 사용하기 위해서, 코틀린은 별도의 배열 클래스들을 제공한다. IntArray, DoubleArray, BooleanArray 등이 있다.

val sevenZerosArray = IntArray(7)
val sevenZerosArrayToo = intArrayOf(0, 0, 0, 0, 0, 0, 0)
val sevenZerosArrayAgain = arrayOf(0, 0, 0, 0, 0, 0, 0).toIntArray()

Enum (열거형)

코틀린의 열거형(Enum)은 class 앞에 enum 키워드를 붙여서 선언한다.

enum class Fruit{ 
  APPLE, PEACH, BANANA
}

enum class Company(val name: String){
    GOOGLE("GOOGLE"),
    APPLE("APPLE"),
    ORACLE("ORACLE");
}

루트 타입

자바의 Object 타입과 비슷하게, 코틀린에서 Any 타입은 non-nullable 타입의 슈퍼타입이다. 자바와 다르게, Any 타입은 Int같은 원시 타입들의 슈퍼타입이기도 하다.

val a: Any = 77     // 자동으로 변환됨
val b: Any? = null  // nullable도 가능

내부적으로 보면, any 타입은 java.lang.Object에 해당한다.

타입 캐스팅

is 연산자는 어떤 타입의 인스턴스인지 체크한다.

fun sizeOf(instance: Any): Int? {
  if(instance is String) return instance.length // 캐스팅이 필요하지 않음
  if(instance is Array<*>) return instance.size
  if(instance is Collection<*>) return instance.size
  return null
}

if(instance !is String) 
  print("$instance is not a String)

as 연산자는 값을 타입에 맞게 캐스팅하려고 시도한다.

fun toInt(instance: Any) {
  val i = instance as Int // 타입이 맞지 않으면 ClassCastException을 발생시킬 수 있다.
}

안전 변환 연산자 as? 는 지정된 타입으로 캐스팅하려고 시도하고 만약 적절한 타입의 값을 가지고 있지 않다면 null을 리턴한다. 안전 변환 연산자는 엘비스 연산자와 같이 자주 사용된다.

val strValOrEmpty = instance as? String ?: "" // instance가 스트링으로 변환될 수 있는 값을 가지고 있으면 스트링으로 변환되고 아니면 ""을 리턴한다.

반복문

코틀린은 별로 새로울 것 없는 while과 do-while 반복문을 지원한다.

코틀린에서 for 반복문은 반복자(iterator)을 제공해야 반복문을 작성할 수 있다. 하지만, 자바와는 다르게 for..in 형태 이외의 다른 형태를 지원하지 않는다.

val language_array = arrayOf("Kotlin", "Swift", "C++")
for(language in language_array) println(language)

대신에, 숫자를 이용해서 반복을 할 때 코틀린은 range (범위) 컨셉을 지원한다. 이름이 알려주는 것처럼, range(범위)는 시작 값과 종료 값이다, 대체로 숫자를 사용하지만 문자로도 가능하다.

// IntRange 타입의 변수로 선언됨
val intOneToTen = 1..10

// CharRange 타입의 변수로 선언됨
val alphabetAtoZ = 'a'..'z'

// 1부터 10까지 출력함
for(i in intOneToTen)
  println("$i of 10")

// a부터 z까지 출력함
for(c in alphabetAtoZ)
  println("$c")

// until 키워드 때문에 1부터 9까지 출력함
for(i in 0 until 10) 
  println("$i of 9")

// 10부터 2 단위로(step 2) 0까지 내려가면서(downTo 0) 출력함. 즉, 10 -> 8 -> 6 ... 0
for (i in 10 downTo 0 step 2)
  println("0까지 $i 남음")

until, downTo, step 은 코틀린의 키워드가 아니라 스탠다드 라이브러리의 infix 확장 함수라고 한다 (나중에 나옴)

연산자 “..”나 range 연산자는 숫자 변수 타입들에게 적용되었을 때, for 구문이 반복하는 기준이 되는 Iterable<Int>의 서브 클래스인 IntRange 객체를 리턴한다 위의 1..10 구문은 내부적으로 0.rangeTo(100)으로 구현되어 있다.

배열이나 콜렉션을 반복할 때, withIndex 스탠다드 라이브러리 함수나 indices 속성을 사용해서 인덱스 값을 계속 추적할 수 있다.

val array = intArrayOf(1, 2, 3, 4, 5)
// withIndex 함수를 사용하면 index 값과 배열의 아이템 값을 같이 접근할 수 있다.
for((index, element) in array.withIndex()) 
  println("array[$index] = $element")

// indices 속성값으로는 index 값만 접근한다
for(index in array.indices)
  println("array[$index] = ${array[index]}")

출력 값:
array[0] = 1
array[1] = 2
array[2] = 3
array[3] = 4
array[4] = 5

map 타입을 반복하는 것도 아주 쉽다.

for((key, value) in my_map)
  println("my_map[$key] = $value")

in 연산자를 사용해서 값이 범위안에 있는지 체크할 수 있다.

var char = 'y';
if(char in 'a'..'z') {
  println("$char는 알파벳 소문자이다.")
} else if(char !in '0'..'9') {
  println("$char는 숫자가 아니다.")
}

함수

코틀린의 함수 선언은 fun 키워드로 시작한다. 그 다음 함수 이름이 오고, 뒤에 파라미터들이 괄호안에 표시된다. 리턴 타입은 파라미터 리스트 뒤에 콜론으로 분리되어서 표시된다.

// Int 타입 i, j 두개의 파라미터를 가지고 Int 타입을 리턴하는 이름이 sum인 함수
// 바디를 블록으로 표시하는 형태
fun sum(i: Int, j: Int) : Int {
  return i + j
}

// 위와 같은 함수지만 표현식 형태
// 리턴 타입이 Int형으로 추론된다
fun sum(a: Int, b: Int) = a + b

// myFuncVar라는 이름의 함수 타입의 변수
var myFuncVar: (Int, Int) -> Int = sum

// myFuncVar라는 이름의 nullable 함수 타입의 변수
var myFuncVar: ((Int, Int) -> Int)? = sum

// Unit은 코틀린의 void형이다. 안 써도 됨
fun myFuncHello(name: String) : Unit {
  println("Hello $name!")
}

코틀린은 탑 레벨 함수를 지원한다; 파일 최상단 레벨에 함수들을 선언할 수 있다, 즉, 꼭 클래스 안에 집어 넣을 필요가 없다.

자바와는 다르게, 코틀린은 디폴트 파라미터들과 이름이 있는 파라미터를 지원한다.

// 스트링 콜렉션을 합쳐서 리턴하는 함수.
// 문장 앞에 붙일 prefix (기본 파라미터 값 ""), 문장 뒤에 붙일 postfix (기본 파라미터 값 ""), 스트링 사이에 넣을 separator (기본 파라미터 값 " ") 등의 인자를 받는다.

fun stringMerger(strings: Collection<String>, separator: String = " ", prefix: String = "", postfix: String = "") : String
{
  val result = StringBuilder(prefix) // prefix로 스프링 빌더를 만듬
// 콜렉션을 반복하면서 계속해서 스트링을 합친다
  for((index, element) in strings.withIndex()) {
    if(index > 0) result.append(separator)
    result.append(element)
  }
  result.append(postfix) // postfix로 문장 뒤를 붙이고
  return result.toString() // 만들어진 문장을 리턴
}

val list = arrayListOf("a", "b", "c", "d", "e")
println(join(list))
println(join(list, separator = ","))
println(join(list, prefix = "(", postfix = ")"))

// 출력 결과
a b c d e
a,b,c,d,e
(a b c d e)

코틀린의 함수들은 vararg 키워드를 사용해서 정해지지 않은 갯수의 파라미터들을 받을 수 있다. 배열을 vararg의 인자로 전달할 때는 스프레드(*) 연산자를 사용한다.

fun sayHello(vararg strings: String) {
  for(str in strings)
    println("Hello $str!")
}

sayHello("C++", "C#", "Kotlin")

// 스프레드(*) 연산자로 배열의 값을 풀어서 호출한다
val languages = arrayOf("C++", "C#")
sayHello(*languages)
sayHello("Kotlin", *languages)

확장 함수 (Extension Functions)

확장 함수는 클래스의 밖에서 정의 되었지만 클래스의 멤버로서 호출되는 함수를 말한다.

package stringExtensionFunc

// 스트링의 마지막 문자가 '!'이면 true를 리턴하는 스트링 타입의 확장 함수 isYelling을 정의
fun String.isYelling(): Boolean = get(length - 1) == '!'

// nullable 스트링 타입의 확장 함수 isNull을 정의
fun String?.isNull(): Boolean = this == null

var str = "hello!"
var str2: String? = null

println(str.isYelling())
println(str.isNull())
println(str2.isNull())

// 실행 결과
true
false
true

확장 함수는 해당하는 클래스의 private이나 protected 멤버에 접근 권한을 갖고 있지 않다. 그리고 당신의 모든 프로젝트에서 사용가능한 것이 아니다. 다른 클래스나 함수들처럼 import가 되어야만 한다.

// 위에서 선언한 stringExtensionFunc 패키지의 isNull과 isYelling함수를 import 시킨다.
import stringExtensionFunc.isNull
import stringExtensionFunc.isYelling

val a: String? = null
val b = "Hello";
val c = "World!";

println(a.isNull());
println(b.isNull());
println(c.isYelling());

// 실행 결과
true
false
true

확장 함수는 클래스의 멤버 함수들과는 약간 다르게 작동하는 것을 알아야한다.

  • 멤버 함수들은 해당하는 오브젝트에서 항상 우선 순위를 갖는다.
  • 확장 함수들은 런타임 타입의 값이 아니라 선언된 정적 타입의 변수로 여겨진다.
fun Any.hello() = println("Any Hello!")
fun String.hello() = println("String Hello!")

val str : String = "Hello"
val obj : Any = str

str.hello() // String의 yell 함수가 실행된다.
obj.hello() // obj 내에는 String의 변수가 할당되어 있지만 Any의 yell함수가 우선 순위를 갖는다.

// 실행 결과
String Hello!
Any Hello!

삽입사 함수 (Infix Funcions)

infix 키워드로 정의된 함수들은 점과 괄호를 제거하고도 호출될 수 있다.

class Price(val value: Double, val currency: Currency)

infix fun Int.euro(cents: Int): Price {
  return Price(toDouble() + cents / 100.0, Currency.EURO)
}

// 형태만 다를 뿐 완전 같은 결과 값을 가진다.
val price1 = 7 euro 77
val price2 = 7.euro(77)

코틀린 스탠다드 라이브러리는 몇몇의 삽입사 함수(Infix Functions)들을 제공한다. 키워드와 혼동하지 않기 바란다.

// until과 step은 삽입사 함수이다. 두개 구문은 완전히 똑같다.
for(i in 0 until 10 step 2) println("$i")
for(i in 0.until(10).step(2)) println("$i")

// downTo도 삽입사 함수이다.
for(i in 10 downTo 0) println("$i")

삽입사 함수들은 반드시 멤버 함수거나 확장 함수여야하고 디폴트 밸류가 없는 파라미터 하나를 가지고 있어야한다. (그래야 줄여서 쓰는 이유가 되니까)

람다 (Lambdas)

람다는 함수로 선언되지 않고 연산 바디 부분이 바로 전달되는 함수를 말한다. 코틀린에서, 람다는 항상 {, }에 둘러쌓여있고, 화살표를 기준으로 왼쪽에 인자들의 리스트와, 오른쪽에 함수 바디 부분을 구현한다.

// 인자 부분 x: Int, y: Int, 함수 바디 부분 x + y 
val sum = { x: Int, y: Int -> x + y };

println(sum(1, 2));

자바와는 다르게, final으로 선언된 변수가 아니라면, 값에 접근 가능하고 값을 바꿀 수도 있다.

var empty = 0

val languages = arrayOf("C++", "C#", "", "")
languages.forEach { element -> if(element.isEmpty()) empty++ }
println(empty);

// 실행 결과
2

함수에 확장 함수로서 람다를 전달하는 것도 가능하다. 타입 선언만 제대로 해주면 된다.

// StringBuilder의 확장 함수를 인자로 받는 함수 선언
fun sayHelloWorld(action: StringBuilder.() -> Unit) : String {
  val sb = StringBuilder()
  sb.action() // action의 인자로 전달된 람다 함수를 실행. 즉, sb.append("Hello "); sb.append("World!");
  return sb.toString()
}

// sayHelloWorld에 append를 2번 실행하는 람다 함수를 전달
// 람다 안에서 this는 StringBuilder를 참조한다.
val say = sayHelloWorld {
  this.append("Hello ")
  this.append("World!")
}

println(say)

인라인 함수 (Inline Functions)

inline을 붙여서 선언된 함수들은 실행되는 것이 아니라 함수가 호출되는 곳으로 바로 대체된다.

inline fun sayHello(action: () -> Unit) {
  try {
    print("Hello ")
    action()
  }finally{
    println("Thank you!")
  }
}

fun main() {
    sayHello {
      println("World!")
    }
}

/* 즉, 아래와 같이 치환된다고 보면 됨
try {
  print("Hello ")
  println("World!")
}finally{
  println("Thank you!")
}
*/

inline 함수는 사용하기에 아주 편리하지만 return이 원하지 않는 곳에서 실행되는 문제가 생길 수도 있다.

// Kotlin의 Array<String> 스탠다드 라이브러리에 forEach 인라인 함수 추가
inline fun Array<String>.forEach(action: (String) -> Unit) {
  for(str in this) {
    action(str)
  }
}

val list = listOf("A", "B", "C")

fun main() {
    // forEach 인라인 함수에 리스트 값을 출력하고 값이 A일 때 종료하는 람다 함수를 전달
    // 하지만 인라인 함수에서 return은 그 함수에서 return을 의미 하는 것이 아니라 치환된 위치에서 return을 실행함
    list.forEach {
        if(it == "B") return  // main에서 return이 실행이 됨
        println(it)
    }

    println("이 라인은 실행 안됨")

}

// 실행 결과
A

위의 문제는 어느 위치에서 return을 할 것인지 명확히 표시함으로써 해결할 수 있다.

// list.forEach 부분을 아래와 같이 변경한다.
list.forEach {
    if(it == "B") return@forEach
    println(it)
}

// 실행 결과
A
C
이 라인은 실행 안됨

When 구문

코틀린의 when 구문은 자바의 switch 구문과 비슷해 보인다. 하지만 훨씬 더 강력하다.

data class Person(val name: String)
    
fun testWhen(obj: Any): String =
    when(obj){
        7 -> "숫자 7입니다."
        is Double -> "Double 타입의 값입니다."
        "철수" -> "문자열 철수 입니다."
        is Person -> "클래스 Person의 객체입니다."
        else -> "값이나 타입을 알 수 없습니다."
    }
    
var person1 = Person("철수")

println(testWhen(7))
println(testWhen(7.7))
println(testWhen("철수"))
println(testWhen(person1))
println(testWhen("??"))

// 실행 결과
숫자 7입니다.
Double 타입의 값입니다.
문자열 철수 입니다.
클래스 Person의 객체입니다.
값이나 타입을 알 수 없습니다.

Exceptions (예외)

exception 객체를 발생시키기 위해서 throw를 사용할 수 있다. 하지만 자바와는 다르게 코틀린에서는 new 키워드를 쓰지 않는다. 그리고 try, catch는 표현식으로 사용된다.

// 숫자를 입력하면 parseInt가 정상 실행되고 숫자가 아닌 값이 입력되면 catch 블록이 실행된다.
import java.io.BufferedReader
import java.io.StringReader

fun printNumber(bufferedReader: BufferedReader): Int? {
  
  val intValue = try {
    Integer.parseInt(bufferedReader.readLine())
  } catch (e: NumberFormatException) {
    throw Exception("인트 아님!")
  }
  
  return intValue;
}

fun main() {
    val reader = BufferedReader(StringReader("777"))
    println(printNumber(reader))
}

// 실행 결과
777

자바 컴파일러는 크리티컬한 예외에 대해서 처리하도록 강제하지만 코틀린은 강제하지 않는다.

// 아래 문장을 실행한다면 자바는 IOException을 처리하기를 강제할 것이다. 하지만 코틀린 컴파일러는 하지 않는다.
bufferedReader.close()

클래스

자바와 똑같이, 코틀린의 클래스도 class 키워드로 선언된다. 하지만 클래스 바디가 없는 경우 {, }는 생략할 수 있다. 자바와는 다르게 코틀린은 new 키워드를 쓰지 않는다.

class NoBodyClass // 바디 없음
class Person(name: String) { ... }
     
val person1 = Person()

생성자

클래스는 하나의 기본 생성자를 가질 수 있다, 그리고 하나 이상의 부수적인 생성자들을 추가할 수 있다. 기본 생성자는 클래스 네임 뒤에 바로 이어진다.

// 프로퍼티 초기화
class Person(_givenName: String, _familyName: String) {
  val givenName = _givenName
  val familyName = _familyName
  val fullName: String
  
  init { // 초기화(init) 블록을 사용
    fullName = "$givenName $familyName"
  }
}

fun main() {
    val person1 = Person("Jonathan", "Cho")
    println(person1.fullName)
}

부수적인 생성자는 클래스 바디에 constructor 키워드를 사용해서 선언한다.

class Person(_givenName: String, _familyName: String) {
  val givenName = _givenName
  val familyName = _familyName
  val fullName: String
  lateinit var parent: Person
    
  constructor(_givenName: String, _familyName: String, _parent: Person) : this(_givenName, _familyName) {
    println("부수적 생성자 실행")
    parent = _parent;
  }
  
  init { // personSon 객체를 생성할 시 초기화 블록이 부수적 생성자보다 먼저 실행된다
    fullName = "$givenName $familyName"
    println("$fullName 초기화 블록 실행")
  }
}

fun main() {
    val personDad = Person("Jonathan", "Cho")
    val personSon = Person("Jonathan Jr.", "Cho", personDad)
    println("----")
    println(personSon.fullName)
    println(personSon.parent.fullName)
}

// 실행 결과
Jonathan Cho 초기화 블록 실행
Jonathan Jr. Cho 초기화 블록 실행
부수적 생성자 실행
----
Jonathan Jr. Cho
Jonathan Cho

상속

코틀린의 공통적 슈퍼 클래스는 Any이다. 즉, 부모 클래스가 선언되지 않는 클래스들의 기본 부모 클래스이다. 명확한 부모클래스를 선언하려면, 헤더에서 클래스 타입을 콜론(:) 뒤에 붙여서 쓰면 된다.

// 위에서 선언한 Person 클래스를 상속해서 슈퍼파워를 가진 Person 클래스를 선언
class PersonWithSuperPower(_givenName: String, _familyName: String, _superPower: String): Person(_givenName, _familyName){
    val superPower = _superPower
}

fun main() {
    val person = PersonWithSuperPower("Jonathan", "Cho", "Telepathy")
    println(person.fullName + "은 " + person.superPower + "를 가지고 있다")
}

// 실행 결과
Jonathan Cho 생성자 실행
Jonathan Cho은 Telepathy를 가지고 있다

속성 (Properties)

위의 많은 예제에서 본 것처럼, 코틀린에서 속성(Properties)은 변경할 수 있는 값 var로 선언하던지, 변경할 수 없는 값 val로 선언할 수 있다. 그리고 간단하게 기본 생성자에만 선언하기도 한다.

class Person {
  val givenName: String
  val familyName: String
}

// 아래와 같이 기본 생성자에만 표현해도 위와 동일하다.
class Person (val givenName: String, val familyName: String)

코틀린에서 프로퍼티는 단순하게 값을 대입하고 가져오는 기본 setter, getter 말고, 커스텀 접근자라고도 불리는 getter와 setter를 가질 수 있다.

class Person() {
  var givenName: String? = null
    set(value) {
      field = value?.capitalize() // 글자의 맨 앞을 대문자로 바꿔서 저장
    }
  var familyName: String? = null
    set(value) {
      field = value?.capitalize()
    }
  val fullName: String
    get() = givenName + " " + familyName // givenName, familyName을 합쳐서 리턴
}

fun main() {
    var person = Person()
    person.givenName = "jonathan"
    person.familyName = "cho"
    println(person.fullName)
}

접근 제한자 (Access Modifiers)

자바와 다르게, 코틀린의 기본 접근 제한자는 public과 final이다. 클래스나 멤버들이 상속이나 오버로딩 되길 원한다면 open 제한자로 명확히 표시해야 한다. 코틀린은 또한 현재 모듈에서만 접근이 가능한 internal 제한자도 제공한다. private 제한자는 탑-레벨 선언의 경우에만 허용된다.

// 코틀린의 기본 접근 제한자 final public
class finalPublicClass

// private을 추가해서 final private 클래스로 만듬. file 내에서만 접근 가능.
private class finalPrivateClass

// internal을 추가해서 final internal 클래스로 만듬. 모듈내에서만 접근 가능.
internal class finalInternalClass

// 상속이 가능하도록 하려면 open 키워드를 사용.
open class subClassablePublicClass {
  
  private val privateVal = "A"
  protected open val overridableProtectedVal = "B"
  internal val onlyVisibleInModuleVal = "C"
  val defaultNonOverridablePublicVal = "D"

  fun defaultFinalPublicFun() { ... }
  open fun overridablePublicFun() { ... }
  protected fun finalProtectedFun() { ... }
  open protected fun overridableProtectedFun() { ... }
  private fun privateFun() { ... }
}

// subClassablePublicClass을 상속함.
class finalPublicSubclass: subClassablePublicClass() {
  // privateVal은 private이므로 접근 불가
  protected open val overridableProtectedVal = "B+"  // subClassablePublicClass의 overridableProtectedVal을 오버라이딩
  // onlyVisibleInModuleVal와 defaultNonOverridablePublicVal은 접근 가능하지만 오버라이딩 불가
  
  // defaultFinalPublicFun은 final이므로 접근 가능하지만 오버라이딩 불가
  open fun overridablePublicFun() { ... }  // 오버라이딩 가능
  // finalProtectedFun은 접근 가능하지만 오버라이딩 불가
  open protected fun overridableProtectedFun() { ... } //오버라이딩 가능
  // privateFun은 private이므로 접근 불가
}

인터페이스 (Interfaces)

추상적 메소드 선언만 가능한 자바의 인터페이스와는 다르게, 코틀린의 인터페이스는 메소드 선언과 구현까지 포함 가능하고 추상적 속성들도(abstract properties) 추가 가능하다.

interface MyInterface {
  val a: Int	// 추상적 val
  fun funA()	// 추상적 메소드
  fun funB() {	// 구현된 메소드 
    println("from funB")
  }
}

class MyClass: MyInterface {
  override val a: Int = 77
    
  override fun funA() { ... }
  override fun funB() { ... } // 바디의 구현 여부와는 상관없이 오버라이딩 가능
}

코틀린의 위임(delegation)에 대한 지원 덕분에 인터페이스를 구현한 파생 클래스는 by 키워드를 사용해서 public 멤버를 특정한 오브젝트에 위임할 수 있다.

interface MyInterface {
  fun funA()
}

class MyClassOne: MyInterface {
  override fun funA() { println("MyClassOne의 funA 메소드 실행") }
}
class MyClassTwo: MyInterface {
  override fun funA() { println("MyClassTwo의 funA 메소드 실행") }
}

class MyInterfaceDelegateClass(myInterface: MyInterface): MyInterface by myInterface

fun main() {
  val myClassOne = MyClassOne()
  val myInterfaceDelegateClassOne = MyInterfaceDelegateClass(myClassOne)
  val myClassTwo = MyClassTwo()
  val myInterfaceDelegateClassTwo = MyInterfaceDelegateClass(myClassTwo)
  
  myInterfaceDelegateClassOne.funA()
  myInterfaceDelegateClassTwo.funA()
}

// 실행 결과
MyClassOne의 funA 메소드 실행
MyClassTwo의 funA 메소드 실행

확장 속성(Extension Properties)

확장 함수와 비슷하게 확장 속성도 클래스의 기능을 간편히 확장할 수 있게 한다.

// 스트링의 마지막 char 값을 저장하고 있는 lastChar란 이름의 확장 속성을 추가한다.
val String.lastChar: Char
    get() = this.get(length - 1)
    
fun main() {
  val myString = "My String"
  println(myString.lastChar)
}

연산자 오버로딩(Operator Overloading)

코틀린은 이미 정의된 연산자들의 구현에 의존하기 보다는 전통적인 메쏘드들을 사용한다. 연산자를 구현하기 위해서는 멤버 함수나 확장 함수를 제공하면 된다. 연산자를 오버로딩하는 함수들은 operator 키워드를 붙여야한다.

class MyString(val string: String) {
  // 마이너스(-) 연산자를 오버로딩함. 오리지널 값에서 원하는 스트링 값을 공백으로 바꾸는 연산자
  operator fun minus(stringToMinus: String): String {
    return string.replace(stringToMinus, "")
  }
}

// 스트링의 확장 함수로 곱하기(*) 연산자를 오버로딩함 
operator fun String.times(multiplier: Int): String {
  return this.repeat(multiplier)
}

fun main() {
  val myString = MyString("이런 바보 멍청아")
  println(myString - "멍청아")
  print("HELLO!" * 7)
}

//실행 결과
이런 바보
HELLO!HELLO!HELLO!HELLO!HELLO!HELLO!HELLO!

데이터 클래스 (Data Classes)

코틀린은 클래스에 data 수식어를 붙이는 것으로 equals, hashCode, toString, copy 메소드를 가지는 데이터 클래스를 쉽게 만들 수 있다.

data class Person(val givenName: String, val familyName: String)

fun main() {
  val person = Person("Jonathan", "Cho")
  println("person : " + person.toString())
  
  val copiedPerson = person.copy();
  println("copiedPerson : " + copiedPerson)
  
  if(person.equals(copiedPerson)){
      print("person, copiedPerson 두 객체는 동일합니다.");
  }
}

// 실행 결과
person : Person(givenName=Jonathan, familyName=Cho)
copiedPerson : Person(givenName=Jonathan, familyName=Cho)
person, copiedPerson 두 객체는 동일합니다.

중첩 클래스(Nested Classes)

자바와는 다르게, 코틀린에서 내부 클래스는 기본적으로는 외부 클래스에 대한 접근이 불가능하다. 물론, 한정어 inner를 써서 명확하게 표시하면 가능하다. 코틀린 내부클래스의 동작은 자바의 static 중첩 클래스와 거의 비슷하다. 하지만 외부 클래스는 내부 클래스의 private 멤버들에 대한 접근이 불가능하다.

Sealed Classes (봉인된 클래스)

Sealed Classes는 객체가 제한된 값들에서 하나의 값을 가질 수 있는 클래스 구조를 표현하기 위해 사용된다. enum class의 확장 버전이라고 생각하면 된다. 다만 enum class는 오직 하나의 객체를 가질 수 있지만 Sealed Class의 서브 클래스는 여러개의 객체를 가질 수 있다. Sealed Class는 추상 클래스이며 서브 클래스는 Sealed Class 자체를 포함해서 동일한 파일 내에 선언되어야 한다.

sealed class Language{
    class Java : Language()
    class Kotlin : Language()
    class Swift : Language()
}
fun eval(lang: Language) =
    when (lang) {
        is Language.Java -> println("Language는 자바이다")
        is Language.Kotlin -> println("Language는 코틀린이다")
        is Language.Swift -> println("Language는 스위프트이다")
    }

fun main() {
    val kotlin = Language.Kotlin()
    eval(kotlin)
}

object 키워드

객체지향 프로그래밍을 하다보면 객체가 딱 하나만 필요할 때가 있다. 자바는 이 싱글턴 패턴을 지원하지 않지만 코틀린은 기본으로 지원한다. object 키워드를 사용하면 클래스를 정의하고 그 클래스의 객체를 동시에 생성할 수 있다.

object PersonJonathanCho {
    val fullname : String= "Jonathan Cho"
    fun isTheNameSame(_fullname : String): Boolean {
       return fullname == _fullname
    }
}

fun main(){
    print(PersonJonathanCho.isTheNameSame("Patric Jo"))
}

코틀린의 클래스는 static 멤버들을 가질 수 없다. 대신에 탑레벨에서 선언하거나 object 키워드를 사용하기 바란다. 하지만 외부 클래스에서 private 멤버에 접근하는 것이 필요하면 companion 오브젝트를 사용하면 된다.

마치면서…

coroutines나 generics 등 아직 다루지 않은 것들이나, 다루기는 했으나 내용이 다소 부족한 부분들도 많다. 분량이 너무 길어질 것이라고 예상한 것들은 다음 문서를 통해서 계속 이어가도록 하겠다.

Koans Exercises로 배운 것을 연습하는 것도 잊지 말고 공식 문서도 많이 참고 하기 바란다. 그리고 웹페이지를 통해서 코드를 실행해 볼 수도 있다.

답글 남기기