코틀린 시작에 앞서 이것저것
코틀린 인 액션을 통해 빠르게 코틀린을 손에 익히고 있다. 코드나 사용법은 손과 눈에 익히면 그만인데 그 외에 언어의 특징은 기록해두려고 한다.
- 자바와 마찬가지로 코틀린도 정적 타입 언어다. (statically typed)
- 정접 타입 언어란, 모든 프로그램 구성 요소의 타입을 컴파일 시점에 알 수 있고 프로그램 안에서 필드나 메서드를 사용할 때마다 컴파일러가 타입을 검증해준다는 뜻
- 동적 타입 언어에는, JVM의 경우 Groovy나 JRuby 등이 있는데 타입과 관계 없이 모든 값을 변수에 넣을 수 있고 메서드나 필드 접근에 대한 검증이 실행 시점에 일어나며, 그에 따라 코드가 더 짧아지고 데이터 구조를 더 유연하게 생성하고 사용할 수 있다.
- 그러나 이러한 동적 타입 언어는 이름을 잘못 입력하는 등의 실수도 컴파일 시 걸러내지 못하고 실행 시점에 오류가 발생한다.
- 자바와 달리 코틀린에서는 모든 변수의 타입을 프로그래머가 직접 명시할 필요가 없다. 대부분 코틀린 컴파일러가 문맥으로부터 변수 타입을 자동으로 유추할 수 있어서 프로그래머는 타입 선언을 생략해도 된다.
- var x = 1 이라고 정의하면 정수 값으로 초기화된다. 코틀린은 이 변수 타입이 Int임을 자동으로 알아낸다. 이렇게 코틀린의 컴파일러가 문맥을 고려해서 변수 타입을 결정하는 기능을 타입 추론 (type inference) 라고 부른다.
- 그래서 정적 타입 지정 언어에서 프로그래머가 직접 타입을 선언해야 하는 불편함이 대부분 사라진다.
- statement(문)과 expression(식)의 차이
- if는 자바에서는 문이지만 코틀린에서는 식이다. 식은 값을 만들어 내고 다른 식의 하위 요소로 계산에 참여할 수 있지만 문은 자신이 둘러싸고 있는 가장 안쪽 블록의 최상위 요소로 존재하고 아무런 값을 만들어내지 않는다는 차이가 있다. 자바는 모든 제어 구조가 문인데, 코틀린에서는 루프를 제외한 대부분의 제어 구조가 식이다. 제어 구조를 다른 식으로 엮어낼 수 있으면 여러 일반적인 패턴을 아주 간결하게 표현할 수 있다.
- 코틀린은 정적 타입 언어라서 컴파일 시점에 모든 식의 타입을 지정해야 하는데, 식이 본문인 함수일 경우 사용자가 반환 타입을 적지 않아도 컴파일러가 식을 분석해서 함수 변환 타입으로 정해준다. (이 코드는 글머리 가장 아래에 있음)
- 자바와 달리 코틀린은,
- 함수를 최상위 수준에 정의할 수 있다. 자바와 달리 클래스 안에 함수를 넣지 않아도 된다.
- System.out.println()이 아니라 println()만 쓰면 된다. println은 표준 자바 라이브러리 함수를 간결하게 사용할 수 있게 감싼 wrapper 함수 중 하나다.
- 세미클론을 붙이지 않아도 된다.
- 코틀린은 nullable type 널이 될 수 있는 타입을 지원한다. 그래서 컴파일 시점에 null pointer exception이 발생할 수 있는지 여부를 검사할 수 있어서 좀 더 프로그램의 신뢰성을 높일 수 있다.
- 함수 타입에 대한 지원이 있다. 이걸 알려면 함수형 프로그래밍을 먼저 알아야 한다.
- 함수는 일급 시민(first-class): 함수를 일반 값처럼 다룰 수 있다. 함수를 변수에 저장할 수도 있고, 함수를 인자로 봐서 다른 함수에 전달할 수도 있고, 함수에서 새로운 함수를 만들어서 반환할 수도 있다.
- 불변성 : 함수형 프로그래밍에서는 일단 만들어지고 나면 내부 상태가 절대로 바뀌지 않는 불변 객체를 사용해 프로그램을 작성한다.
- side effect가 없다 : 함수형 프로그래밍에서는 입력이 같으면 항상 같은 출력을 내놓고 다른 객체의 상태를 변경하지 않으며, 함수 외부나 다른 바깥 환경과 상호작용하지 않는 순수함수(pure function)을 사용한다.
- 프로그래머가 작성하는 코드에서 의미가 없는 부분을 줄이려고 노력했다. getter, setter, 생성자 파라미터를 필드에 대입하기 위한 로직 등 자바에 존재하는 여러가지 번거로운 준비 코드를 코틀린에서는 묵시적으로 제공하기 때문에 이로 인해 지저분해지는 일이 없다. 그리고 컬렉션에서 원소를 찾는 것 같은 일반적인 작업을 수행할 때 명시적으로 작성해야만 하는 코드의 양이 상당한데, 코틀린은 람다를 통해 코드를 간결하게 할 수 있다.
// 블록이 본문인 함수
fun max(a: Int, b: Int): Int {
return if (a > b) a else b
}
// 식이 본문인 함수. 코틀린에서는 이게 자주 쓰임.
fun max(a:Int, b: Int): Int = if(a>b) a else b
// 반환 타입을 생략해서 더 간략하게 만들 수도 있다. 반환 타입을 생략해도 코틀린이 타입 추론해준다.
fun max(a: Int, b: Int) = if (a > b) a else b
함수형 프로그래밍에 대해
함수형과 명령형 프로그램은 코드를 구성하고 실행하는 방법에서 차이가 있다.
명령형 프로그램은 코드가 어떻게 수행되는지를 명시적으로 나타내기 때문에 주로 반복문과 조건문처럼 값을 제어하는 구조로 코드를 작성한다. 각각의 값의 상태를 변환해서 새로운 리스트로 선언한다.
반면 함수형 프로그램은 코드가 어떻게 수행되는지 명시적으로 보여주는 것은 단순하게 표현한다. 함수형 프로그래밍은 선언형 프로그램의 일종인데, 선언으로만 프로그램을 동작시킨다. 구체적인 작동 순서를 나열하지 않는다.
// 명령형
fun main() {
val numbers = listOf(1, 2, 3, 4, 5)
val doubledNumbers = mutableListOf<Int>()
for (number in numbers) {
doubledNumbers.add(number * 2)
}
println(doubledNumbers) // 출력: [2, 4, 6, 8, 10]
}
// 함수형
fun main() {
val numbers = listOf(1, 2, 3, 4, 5)
val doubledNumbers = numbers.map { it * 2 }
println(doubledNumbers) // 출력: [2, 4, 6, 8, 10]
}
// 명령형
public int getPoint(Customer customer) {
for (int i = 0; i < customers.size(); i++) {
Customer c = customers.get(i);
if (customer.equals(c)) {
return c.getPoint();
}
}
return NO_DATA;
}
// 선언형
public int getPoint(Customer customer) {
if (isRegisteredCustomer(customer)) {
return findCustomer(customer).getPoint();
}
return NO_DATA;
}
함수형 프로그래밍의 장점은?
일단 강력한 추상화로 코드 중복을 피할 수 있다. 함수형 프로그래밍은 함수를 값처럼 사용할 수 있고 이걸 통해 강력한 추상화가 가능하다. 그 예시는 아래와 같다. filterNumbers는 주어진 조건에 따라 숫자를 필터링할 수 있는데 이 조건 자체를 인자로 받고 있다.
fun filterNumbers(numbers: List<Int>, predicate: (Int) -> Boolean): List<Int> {
return numbers.filter(predicate)
}
fun main() {
val numbers = listOf(1, 2, 3, 4, 5)
val evenNumbers = filterNumbers(numbers) { it % 2 == 0 }
println(evenNumbers) // 출력: [2, 4]
val oddNumbers = filterNumbers(numbers) { it % 2 != 0 }
println(oddNumbers) // 출력: [1, 3, 5]
}
두번째로는 다중 스레드를 사용해도 안전하다는 것이다. 다중 스레드 프로그램에서는 적절한 동기화 없이 같은 데이터를 여러 스레드가 변경하는 경우에 가장 많은 문제가 생긴다. 불변 데이터 구조를 사용하고 순수 함수를 그 데이터구조에 적용한다면 다중 스레드 환경에서 같은 데이터를 여러 스레드가 변경할 수 없다. 따라서 복잡한 동기화를 적용하지 않아도 된다.
data class ImmutableCounter(private val count: Int = 0) {
fun increment(): ImmutableCounter {
return ImmutableCounter(count + 1)
}
fun getCount(): Int {
return count
}
}
fun main() {
val counter = ImmutableCounter()
// 스레드 1
Thread {
repeat(1000) {
// 동일한 counter 객체를 여러 스레드에서 사용
val updatedCounter = counter.increment()
println("Thread 1: ${updatedCounter.getCount()}")
}
}.start()
// 스레드 2
Thread {
repeat(1000) {
// 동일한 counter 객체를 여러 스레드에서 사용
val updatedCounter = counter.increment()
println("Thread 2: ${updatedCounter.getCount()}")
}
}.start()
}
위 코드에서 ImmutableCounter 클래스는 불변 데이터 구조고, increment 함수는 현재 상태를 변경하거나 바깥 환경과 상호작용하지 않고 새로운 ImmutableCounter 객체를 반환하는 순수 함수다. 또한 getCount 함수도 상태를 변경하는 것이 아니라 현재의 count 값을 반환한다. 여기서 Thread를 생성하고 실행하는 부분은 비동기적으로 실행되고, 두 스레드는 이로 인해 병렬적으로 실행될 수 있다. 위 코드에서는 두 개의 스레드가 동시에 카운터 값을 증가시키고 그걸 출력하고 있지만, 실제로는 불변 데이터 구조(ImmutableCounter)와 순수 함수(increment)를 사용하고 있으므로 데이터를 변경하지 않고 스레드 간의 안전한 동시성을 보장할 수 있다. 즉, 각 스레드에서 동일한 ImmutableCounter 객체를 공유하고 있지만 서로가 영향을 주지 않고, 각 스레드에서 독립적으로 증가된 카운터 값이 출력된다.
세번째로는 다른 객체의 상태를 변경하지 않고 함수 외부나 바깥 환경과 상호작용하지 않는 순수함수를 사용하기 때문에 테스트하기도 쉽다는 것이다. 순수함수가 아닌 함수는 side effect가 있을 수 있고 이 함수를 실행하는 데에 필요한 환경을 구성하는 코드가 따로 필요하지만, 순수 함수는 그런 준비 코드 없이 독립적으로 테스트할 수 있다.
코틀린의 함수형 프로그래밍 지원
자바 8 이전에는 함수형 프로그래밍을 지원할 수 있는 기능이 거의 없었는데, 코틀린은 처음부터 함수형 프로그래밍을 풍부하게 지원해 왔다.
- 함수 타입을 지원함에 따라 어떤 함수가 다른 함수를 파라미터로 받거나 함수가 새로운 함수를 반환할 수 있다.
- 람다 식을 지원해서 번거로운 준비 코드를 작성하지 않아도 코드 블록을 쉽게 정의하고 여기저기 전달할 수 있다.
- 데이터 클래스는 불변인 value object를 편하게 만들 수 있는 구문을 제공한다.
- 코틀린 표준 라이브러리는 객체와 컬렉션을 함수형 스타일로 다룰 수 있는 API를 제공한다.
3을 보여주는 예시는 아래와 같다. Person 클래스는 데이터 클래스로 선언되어 있고, 이러한 데이터 클래스를 사용하면 equals() hashCode() toString() 등의 메서드를 자동으로 생성해주고, 불변한 value object를 쉽게 만들 수 있다.
data class Person(val name: String, val age: Int)
fun main() {
// 불변한 value object 생성
val person1 = Person("Alice", 30)
val person2 = Person("Bob", 25)
// 값을 변경할 수 없음 (불변성 보장)
// person1.name = "Charlie" // 에러: Val cannot be reassigned
}
4처럼 함수형 스타일로 객체와 컬렉션을 다루는 예시는 아래와 같다. filter 함수를 통해 조건에 맞는 요소를 필터링하고 map 함수를 통해 각 요소를 변환한다.
fun main() {
// 객체와 컬렉션을 함수형 스타일로 다루기
// Map을 사용하여 객체 생성
val personMap = mapOf(
"Alice" to 30,
"Bob" to 25,
"Charlie" to 35
)
// filter와 map을 사용하여 컬렉션을 변환
val adults = personMap.filter { it.value >= 30 }.map { Person(it.key, it.value) }
// 결과 출력
adults.forEach { println("${it.name} is an adult") }
}
코틀린이 방지하는 예외
- NullPointerException을 컴파일 시점 검사를 통해 방지한다.
- 널 여부를 표시할 땐 ? 를 통해 간단하게 표시가 가능하다.
- ClassCastException을 방지한다.
- 어떤 객체를 다른 타입으로 cast 하기 전에 타입을 미리 검사하지 않으면 ClassCastException이 발생하는데, 코틀린에서는 (타입검사 + 실제 캐스트)를 하나의 연산자로 할 수 있다.
if (value is String) // 타입을 검사하고
println(value.toUpperCase()) // 해당 타입의 메서드를 바로 사용한다.
코틀린의 빌드
- 컴파일은, 소스코드를 기계어나 중간 코드로 변환하는 과정으로, 컴퓨터가 실행할 수 있는 코드로 변환한다.
- 빌드는 컴파일 과정을 포함하고 이외에도 각종 리소스들을 .class 파일들이 참조할 수 있게 위치를 옮긴다든지, 라이브러리나 패키지 등의 결과물을 생성한다든지 이러한 과정을 거쳐서 실행할 수 있는 독립적인 형태로 변환하는 과정이다. 빌드의 결과물로 주로 jar 파일이 사용되고, 일반적으로 jar 파일이 배포된다.
아무튼, 코틀린의 소스코드는 보통 .kt 라는 확장자로 되어 있다. 코틀린 컴파일러는 자바 컴파일러가 자바 코드를 컴파일할 때와 마찬가지로 코틀린 소스를 분석해서 똑같이 .class 파일을 만든다. kotlinc 명령을 통해 코틀린 코드를 컴파일하고 나면 java 명령으로 그 코드를 실행할 수 있다. kotlin으로 개발된 프로젝트를 컴파일하고 패키징해서 배포할 때, 일반적으로 jar 파일 형식으로 만들어진다. (jar 안에 코틀린 코드와 이에 대한 바이트코드와 리소스가 포함됨. 배포할 때 쓰이는 파일 형식임.)