Kotlin in Action

[Kotlin in Action] part2_Kotlin basics

지 슈 2022. 10. 7. 02:02

기본 구성 요소: 함수와 변수

fun main(args: Array<String>) {
println("Hello, world!")
}

위 짧은 예시에서:

1. fun 키워드는 함수 선언을 할 때 사용된다. 

2. 매개 변수 타입은 이름 뒤에 선언된다.

3. 함수는 파일의 최상위 수준에 선언된다. 

4. 배열은 일반적인 클래스이다. 자바와 다르게 코틀린은 배열 타입을 선언하기 위한 문법이 존재하지 않는다.

5. System.out.println 대신 println이라고 쓴다.

6. 줄 끝에 세미콜론(;)을 붙이지 않아도 된다.

 

함수

fun max(a: Int, b: Int): Int {
return if (a > b) a else b
}
>>> println(max(1, 2))
2

 

이 코드는 더욱 간단해질 수 있다. 

fun max(a: Int, b: Int): Int = if (a > b) a else b

 

함수의 본문이 줄 괄호로 쌓여 있다면 이 함수가 블록이 본문인 함수라고 부르고, 등호와 식으로 이뤄진 함수는 식이 본문인 함수라고 부른다.

 

인텔리J 아이디어 팁

IntelliJ IDEA 는 이 두 방식의 함수를 변환하는 메뉴가 있다. 'Convert to expression body(식 본문으로 변환)', 'Convert to block body(블록 본문으로 변환)'이다.

 

반환 타입을 고려하면 이 max 함수를 더욱 간단하게 만들 수 있다.

fun max(a: Int, b: Int) = if (a > b) a else b

왜 리턴 타입이 없는 함수가 존재하는 것일까? 모든 변수와 식에는 타입이 있고, 모든 함수에는 리턴 타입이 존재한다. 하지만 식이 본문인 함수에 대해서는, 컴파일러가 함수 본문 식을 분석해서 식의 결과 타입을 함수 리턴 타입으로 정해준다.

 

변수

자바에서 변수 타입을 지정하면서 변수를 선언하기 시작한다. 이와 달리 코틀린은 키워드로 변수를 시작하고, 변수의 이름 다음에 변수의 타입을 선언한다. 

val question =
"The Ultimate Question of Life, the Universe, and Everything"
val answer = 42

이 예시에서는 타입 선언을 생략했다. 하지만 원한다면 타입을 지정할 수 있다.

val answer: Int = 42

식이 본문인 함수처럼, 변수의 타입을 지정하지 않는다면 컴파일러가 추론하여 변수의 타입을 자동적으로 지정한다.

 

변경 가능한 변수와 변경 불가능한 변수
  • val (value에서 파생): 변경 불가능하다. 초기화된 이후에 수정할 수 없다.
  • var (variable에서 파생): 변경 가능하다. 변수 선언 이후 수정 가능하다.

모든 변수를 val 키워드를 적용해서 사용할 수 있다. 필요한 경우에만 var 키워드를 사용하면 되기 때문이다. 

val 을 사용하는 변수는 블록이 정의되는 곳에서 딱 한번 초기화해야 한다. 하지만 조건에 따라 val 값을 다른 여러 값으로 초기화할 수도 있긴 하다.

val message: String
if (canPerformOperation()) {
message = "Success"
// ... perform the operation
}
else {
message = "Failed"
}

주의할 것은, val 레퍼런스가 변경 불가능하고 수정 불가능하지만, 값이 가리키는 객체는 변경 가능할 수도 있다는 것이다. 예를 들어 아래 예시는 충분히 입증가능하다.

val languages = arrayListOf("Java")
languages.add("Kotlin")

변수가 var 참조일 때 값이 변경 가능하긴 하지만 변수의 타입은 고정되어야 한다.

var answer = 42
answer = "no answer"

예를 들어 위 예시는 컴파일 불가능하다. answer 의 타입은 Int 인데 String 값으로는 초기화 될 수는 없기 때문이다.

 

더 쉬운 문자열 형식 지정 방법: 문자열 템플릿 

fun main(args: Array<String>) {
val name = if (args.size > 0) args[0] else "Kotlin"
println("Hello, $name!")
}

 

변수를 선언한 뒤 변수 이름 앞에 $ 문자를 붙인다. $ 문자를 문자열에 포함하고 시키면 println("\$x") 와 같이 \를 사용해서 $를 이스케이프 시킨다. 

 

클래스와 프로퍼티

이 섹션에서는 클래스를 선언할 때의 기본 문법에 대해서 알 수 있다. 

아래 예시에서 자바로 name이라는 프로퍼티를 가지고 있는 Person 클래스를 선언했다.

/* Java */
public class Person {
private final String name;
public Person(String name) {
this.name = name;
}
public String getName() {
return name;
}
}

자바의 코드는 완전히 반복되는 코드를 종종 포함한다. 해당 이름을 가진 필드에 매개 변수를 할당한다. 코틀린에서는 이 문법을 훨씬 간단하게 사용할 수 있다.

class Person(val name: String)

이렇게 자바-코틀린 변환기를 사용해서 Person 클래스를 코틀린으로 옮기면 간단해진다. public 가시성 변경자가 사라졌음을 확인할 수 있다. 코틀린의 기본 가시성은 public 이므로 변경자를 생략해도 오류가 나지 않는다.

 

프로퍼티

자바에서는 필드와 해당 필드의 접근자의 조합을 프로퍼티라고 부른다. 코틀린에서 프로퍼티는 필드와 접근자 메소드를 완전히 대체한다. 프로퍼티는 변수와 마찬가지로 val 와 var 키워드를 사용하여 선언한다.

class Person(
val name: String,
var isMarried: Boolean
)

 

TIP 자바에서 정의된 클래스에서도 코틀린의 프로퍼티 문법을 적용할 수 있다. 자바 클래스의 getter는 val 프로퍼티로 접근 가능하고, getter/setter 는 var 프로퍼티로 접근 가능하다. 예를 들어 자바 클래스가 getName 과 setName이라는 메소드를 정의한다면 name이라는 프로퍼티로 접근 가능하다는 것이다.

 

코틀린 소스코드 구조: 디렉토리와 패키지

코틀린에도 자바의 패키지와 비슷한 개념이 있다. 모든 코틀린 파일은 package 문을 처음에 사용할 수 있는데 모든 선언(클래스, 함수, 프로퍼티)은 그 패키지에 포함된다. 같은 패키지에 있는 서로 다른 선언은 함께 사용가능하다. 다른 패키지에 있는 경우 함께 사용하기 위해서는 import 해야한다. 자바처럼 import 도 파일의 시작부분에 위치하고 import 키워드를 사용한다.

 

코틀린은 import 하는 클래스와 함수를 구별하지 않는다. import 키워드를 사용한다면 어떤 종류의 선언도 import 될 수 있다.

 

어떤 패키지로부터 선언하기 위해 패키지 이름 다음에 .* 를 입력할 수도 있다. 이를 통해 패키지에 정의된 클래스 뿐만 아니라 상위 레벨 함수와 프로퍼티까지 표시할 수 있다.

자바에서는 위 그림처럼 shapes 패키지의 클래스는 개별적인 파일에 저장되고, 해당되는 패키지의 이름으로만 저장될 수 있다. 코틀린에서는 여러 개의 클래스를 같은 파일에 저장하고 어떠한 이름으로든 저장이 가능하다. 코틀린은 파일을 정렬할 때 어떠한 규칙도 강제하지 않는다. 아래 예시처럼, shapes 파일을 따로 생성하지 않아도 geometry.shapes 패키지를 geometry 폴더에 저장할 수 있다.

 

선택 표현과 처리: enum과 when

when 구조는 자바의 switch 구조를 대체하지만 더욱 강력하고 자주 사용된다.

enum 클래스 선언

코틀린에서 enum은 부드러운 키워드(soft keyword)라고 불리는데 이는 class 전에 오기도 하면서 다른 위치에서는 일반적인 이름으로 사용될 수도 있는 것이다.(?) class 는 여전히 키워드고 개발자는 변수의 이름을 clazz 또는 aClass 라고 선언할 것이다.???

자바처럼 enum은 단순히 값의 열거가 아니다. enum 클래스 안에서도 프로퍼티나 메소드의 정의가 가능하다.

enum class Color(
val r: Int, val g: Int, val b: Int
) {
RED(255, 0, 0), ORANGE(255, 165, 0),
YELLOW(255, 255, 0), GREEN(0, 255, 0), BLUE(0, 0, 255),
INDIGO(75, 0, 130), VIOLET(238, 130, 238);
fun rgb() = (r * 256 + g) * 256 + b
}
>>> println(Color.BLUE.rgb())
255

enum 상수를 선언할 때는 그 선언의 프로퍼티 값을 제공해야 한다. 이 예시는 코틀린에서 세미콜론(;)을 사용해야 하는 유일한 경우를 보여준다: enum 클래스에서 어떤 메소드를 정의할 때 세미콜론(;)은 enum 상수 목록과 메소드 정의 사이에 넣어야 한다.

 

when 으로 enum 클래스 다루기

코틀린에서는 식-본문인 함수를 작성하고 when 표현으로 리턴할 수 있다. (1장에서 여러 개의 줄이 있는 함수는 식-본문을 사용하기로 했다.) 예를 들어:

fun getMnemonic(color: Color) =
when (color) {
Color.RED -> "Richard"
Color.ORANGE -> "Of"
Color.YELLOW -> "York"
Color.GREEN -> "Gave"
Color.BLUE -> "Battle"
Color.INDIGO -> "In"
Color.VIOLET -> "Vain"
}
>>> println(getMnemonic(Color.BLUE))
Battle

자바의 switch문과 달리 when 구조에서는 break 문을 사용하지 않는다.

 

when과 임의의 객체를 함께 사용

상수만을 사용할 수 있는 자바의 switch 문과 달리 when 구조는 어떤 객체도 사용할 수 있다. 

fun mix(c1: Color, c2: Color) =
when (setOf(c1, c2)) {
setOf(RED, YELLOW) -> ORANGE
setOf(YELLOW, BLUE) -> GREEN
setOf(BLUE, VIOLET) -> INDIGO
else -> throw Exception("Dirty color")
}
>>> println(mix(BLUE, YELLOW))
GREEN

코틀린의 표준 라이브러리에는 setOf 함수가 있는데 객체를 포함하는 Set을 만든다. 객체는 순서 상관없이 동등하다. 예를 들어 setOf(c1,c2)와 setOf(RED,YELLOW)는 동등하고, c1이 RED이거나 YELLOW라는 것은 상관없다.

 

인자 없는 when 사용

함수가 여러 번 호출될 때, 함수를 다른 방식으로 고쳐 쓰는 것으로 가비지 객체가 늘어나는 것을 방지할 수 있을 것이다. 

fun mixOptimized(c1: Color, c2: Color) =
when { 
(c1 == RED && c2 == YELLOW) ||
(c1 == YELLOW && c2 == RED) ->
ORANGE
(c1 == YELLOW && c2 == BLUE) ||
(c1 == BLUE && c2 == YELLOW) ->
GREEN
(c1 == BLUE && c2 == VIOLET) ||
(c1 == VIOLET && c2 == BLUE) ->
INDIGO
else -> throw Exception("Dirty color")
}
>>> println(mixOptimized(BLUE, YELLOW))
GREEN

추가 객체를 만들지 않는다는 장점이 있지만 가독성이 떨어진다는 단점이 있다. 

 

스마트 캐스트: 타입 검사와 캐스트를 조합

(1+2)+4 를 계산하는 함수라면

interface Expr
class Num(val value: Int) : Expr
class Sum(val left: Expr, val right: Expr) : Expr

1. 식은 트리 구조로 저장된다. Num은 노드 말단, Sum은 자식 둘이 있는 중간 노드, sum 의 두 자식은 덧셈의 인자이다.

2. Expr 인터페이스와 인터페이스를 구현하는 Num, Sum 클래스가 있다.

3. 클래스가 인터페이스를 구현하는 것을 명시할 때 콜론(:)을 인터페이스 뒤에 붙인다.

 

Sum은 left, right 인자를 가진다. (1+2)+4를 표현하기 위해서 Sum(Sum(Num(1), Num(2)), Num(4)) 객체를 생성한다.

이 식을 계산하기 위해서 Expr 인터페이스는 두 번 시행된다.

  • Number일 경우: 대응되는 값을 리턴한다.
  • Sum일 경우: left, right 값을 고려하여 두 값의 합을 리턴한다.
fun eval(e: Expr): Int {
if (e is Num) {
val n = e as Num
return n.value
}
if (e is Sum) {
return eval(e.right) + eval(e.left)
}
throw IllegalArgumentException("Unknown expression")
}
>>> println(eval(Sum(Sum(Num(1), Num(2)), Num(4))))
7

코틀린에서는 is 를 사용하여 변수의 타입을 확인한다. 자바와 달리 코틀린에서 변수의 타입 검사를 위해 명시적으로 캐스트를 추가할 필요가 없다. 컴파일러가 캐스트를 실행해주기 때문에 스마트 캐스트(smart cast) 라고 불리는 것이다.

 

스마트 캐스트는 변수가 is 검사 후 변수가 변경될 수 없는 경우에만 작동한다.

 

리팩토링: 'if'를 'when'으로 대체

코틀린의 if는 자바의 if와 어떻게 다를까? 자바와 달리, 코틀린에는 if가 값을 만들어내기 때문에 3항 연산자가 따로 없다.

return 문 대신 함수 본문에 if 문을 쓸 수 있는 것이다.

*3항 연산자: true/false 를 판단할 수 있는 조건식(피연산자1) ? 값or연산식(피연산자2) : 값or연산식(피연산자3);

fun eval(e: Expr): Int =
if (e is Num) {
e.value
} else if (e is Sum) {
eval(e.right) + eval(e.left)
} else {
throw IllegalArgumentException("Unknown expression")
}
>>> println(eval(Sum(Num(1), Num(2))))
3

when 절을 이전에 봤던 동등성 검사 뿐만 아니라 다른 의도로 사용할 수도 있다.

fun eval(e: Expr): Int =
when (e) {
is Num ->
e.value
is Sum ->
eval(e.right) + eval(e.left)
else ->
throw IllegalArgumentException("Unknown expression")
}

 

대상을 이터레이션: while 과 for 루프

코틀린이 자바와 가장 비슷한 부분은 이터레이션이다. while 루프는 자바에서와 똑같다. for 루프는 자바의 for-each 루프 형식으로만 사용한다.

 

while 루프

while 루프, do-while 루프가 있고, 두 루프의 문법은 자바에서 해당 루프의 문법과 다르지 않다.

 

수에 대한 이터레이션: 범위와 수열

fizzbuzz 게임: 3의 배수일 때 fizz, 5의 배수일 때 buzz, 3과 5의 공배수일 때 fizzbuzz.

범위는 1~100

fun fizzBuzz(i: Int) = when {
i % 15 == 0 -> "FizzBuzz "
i % 3 == 0 -> "Fizz "
i % 5 == 0 -> "Buzz "
else -> "$i "
}
>>> for (i in 1..100) {
... print(fizzBuzz(i))
... }
}
1 2 Fizz 4 Buzz Fizz 7 ...

 

**책에 없는 내용

특정 범위의 값(range of values)을 생성하는 방법: kotlin.ranges 패키지에 있는 rangeTo() 함수가 있고, 연산자 '..' 사용.

보통 rangeTo()는 in 또는 !in 함수와 함께 사용된다.

 

맵에 대한 이터레이션

가장 많이 사용되는 for...in 루프는 컬렉션에 대한 이터레이션이다. 자바와 같은 방식으로 실행된다.

그 다음은 맵에 대한 이터레이션에 대해 알아보자

문자에 대한 2진수 형식을 출력하는 프로그램이 있다. 맵에 이 2진수 배열을 저장하고, 맵에 저장된 것을 출력해보자.

val binaryReps = TreeMap<Char, String>()
for (c in 'A'..'F') {
val binary = Integer.toBinaryString(c.toInt())
binaryReps[c] = binary
}
for ((letter, binary) in binaryReps) {
println("$letter = $binary")
}

1. '..' 은 숫자 뿐만 아니라 문자에도 사용할 수 있다.

2. for 루프는 이터레이션되는 컬렉션의 요소를 언팩(unpack)하게 한다.

3. 언팩한 결과를 두 변수에 저장한다: letter 은 key를 리턴, binary 는 value를 리턴한다.

4. get, put 보다 간단한 문법 사용: map[key]로 값을 읽고, map[key] = value 로 초기화한다.

 

in으로 컬렉션이나 범위의 원소 검사

in 연산자를 사용해서 값이 범위 안에 포함되는지 검사할 수 있고, !in 연산자를 사용해서 값이 범위 안에 포함이 안되는지 검사할 수 있다.

fun isLetter(c: Char) = c in 'a'..'z' || c in 'A'..'Z'
fun isNotDigit(c: Char) = c !in '0'..'9'
>>> println(isLetter('q'))
true
>>> println(isNotDigit('x'))
true

in 과 !in 연산자는 when 절에서도 사용가능하다.

 

코틀린의 예외 처리

자바와 달리 코틀린은 throw 구조로 예외처리 가능하다.

val percentage =
if (number in 0..100)
number
else
throw IllegalArgumentException( 
"A percentage value must be between 0 and 100: $number")

이 예시에서 조건이 만족되면 프로그램은 정상적으로 돌아가고 percentage 변수는 number 로 초기화된다. 조건이 만족되지 않는 경우, 예외는 'throw' 되고, 변수는 초기화되지 않는다.

 

try, catch, finally

자바에서처럼, 예외를 처리하기 위해 try 구조를 catch 와 finally를 사용할 수 있다. 

fun readNumber(reader: BufferedReader): Int? {
try {
val line = reader.readLine()
return Integer.parseInt(line)
}
catch (e: NumberFormatException) {
return null
}
finally {
reader.close()
}
}
>>> val reader = BufferedReader(StringReader("239"))
>>> println(readNumber(reader))
239

자바와 가장 큰 차이는 throws절이 코드에 없다는 것이다. 자바에서는 함수를 작성할 때 함수 선언 후에 throwsIOException을 붙여야 한다. IOException이 체크 예외이기 때문이다.???

 

try를 식으로 사용

자바와 코틀린의 다른 차이이다.

if 와 when 처럼 코틀린의 try 키워드도 식이다. 그래서 try 의 값을 변수에 대입할 수 있다.

하지만 if와 달리, 본문을 중괄호로 닫아야 한다. 

fun readNumber(reader: BufferedReader) {
val number = try {
Integer.parseInt(reader.readLine()) 
} catch (e: NumberFormatException) {
return
}
println(number)
}
>>> val reader = BufferedReader(StringReader("not a number"))
>>> readNumber(reader)

이 예시에서는 리턴문을 catch 블록에 넣는다. 이로 인해 함수가 시행됐을 때 catch 블록 이후에 멈출 수 있다.

함수를 계속 시행하려면 catch 문도 값을 가져야 한다.

 

catch 문이 값을 리턴하면:

fun readNumber(reader: BufferedReader) {
val number = try {
Integer.parseInt(reader.readLine())
} catch (e: NumberFormatException) {
null
}
println(number)
}
>>> val reader = BufferedReader(StringReader("not a number"))
>>> readNumber(reader)
null

 

요약

  • 함수를 정의할 때 fun 키워드 사용한다. val, var는 각각 변경 불가능한 변수, 변경 가능한 변수이다.
  • 문자열 템플릿을 사용하면 문자열을 연결하지 않아도 코드를 간결하게 쓸 수 있다. 변수 앞에 $ 문자를 붙이거나 식을 $(~~) 처럼 둘러쌓으면 변수나 식의 값을 문자열 안에 넣을 수 있다.
  • 코틀린에서는 값 객체 클래스를 간결하게 쓸 수 있다.
  • 코틀린에서 when 절은 자바의 switch와 비슷하지만 더욱 강력하다.
  • if는 코틀린에서 식이고, 값을 리턴한다.
  • 변수의 타입 검사 후 그 변수를 캐스팅하지 않아도 검사한 타입의 변수처럼 사용 가능하다. 컴파일러가 스마트 캐스트를 활용해서 자동으로 타입을 바꿔준다.
  • for, while, do-while 루프는 자바와 비슷하다. 하지만 코틀린의 for은 더욱 편리하다. 맵을 이터레이션 하거나 이터레이션하면서 컬렉션의 원소와 인덱스를 함께 사용하는 경우 코틀린의 for을 더 편리하게 쓸 수 있다.
  • '..'을 사용해서 범위를 만들어낼 수 있다. 어떤 값이 범위에 포함되는지 확인하려면 in, 범위에 포함되지 않는지 확인하려면 !in을 사용한다.
  • 코틀린 예외처리는 자바와 비슷하지만 함수가 던질(throw) 수 있는 예외를 선언하지 않아도 된다.