자바와 마찬가지로, 코틀린 클래스도 class 키워드 다음에 클래스 이름이 오고 그다음에 클래스 본문이 오는 형태로 정의된다.
class Person {
// 프로퍼티
var firstName: String = ""
var familyName: String = ""
var age: Int = 0
// 함수
fun fullName() = "$firstName $famailyName"
// 함수
fun showMe() {
println("${fullName()}: $age")
}
}
프로퍼티는 어떤 클래스의 구체적인 인스턴스와 엮여 있기 때문에 이 인스턴스를 식으로 지정해야 한다.
이런 인스턴스를 수신 객체(receiver)라 부르고, 수신 객체는 프로퍼티에 접근할 때 사용해야 하는 객체를 지정한다.
멤버 함수의 경우에도 똑같이 수신 객체가 있고, 이런 경우 멤버 함수를 메서드(method)라 부른다.
fun showFullName(p: Person) = println(p.fullName())
클래스 내부에서는 this 식으로 수신 객체를 참조할 수 있다.
대부분의 경우 this를 디폴트로 가정하기 때문에 수신 객체의 멤버 안에서 수신 객체의 멤버를 참조할 때는 this를 생략해도 된다.
class Person {
var firstNmae: String = ""
var familyName: String = ""
var age: Int = 0
fun fullName() = "${this.firstName} ${this.familyName}"
fun showMe() {
println("${this.fullName()}: ${this.age}")
}
// this 생략
fun fullName() = "$firstName $famailyName"
// this 생략
fun showMe() {
println("${fullName()}: $age")
}
}
하지만 this가 꼭 필요한 경우도 있다.
어떤 클래스의 프로퍼티와 메서드 파라미터 이름이 같은 경우, 이 둘을 구분하기 위해 프로퍼티 이름 앞에 this를 써야 한다.
class Person {
var firstNmae: String = ""
var familyName: String = ""
// 프로퍼티와 함수의 파라미터 명이 같은 경우 구분을 위해 this 필수
fun setName(firstName: String, familyName: String) {
this.firstName = firstName
this.familyName = familyName
}
}
생성자는 클래스 인스턴스를 초기화해주고 인스턴스 생성 시 호풀되는 특별한 함수다.
class Person(firstName: String, familyName: String) {
val fullName = "$firstName $familyName"
}
fun main() {
val person = Person("Dawn", "B")
println(person.fullName) // Dawn B
}
코틀린에서는 생성자를 호출할 때 new와 같은 특별한 키워드를 사용하지 않는다.
클래스 헤더의 파라미터 목록을 주 생성자(primary constructor) 선언이라고 부른다.
주 생성자는 함수와 달리 본문이 하나가 아니다. 대신 주 생성자는 클래스 정의 내에서 프로퍼티 초기화와 초기화 블록이 등장하는 순서대로 구성된다.
초기화 블록이란 init이라는 키워드가 앞에 붙은 블록이다. 이 블록 안에서 클래스 초기화 시 필요한 간단하지 않은 초기화 로직을 수행할 수 있다.
class Person(firstName: String, familyName: String) {
val fullName = "$firstName $familyName"
init {
println("Created new Person instance: $fullName")
}
}
클래스 안에는 init 블록이 여럿 들어갈 수 있다. 그리고 초기화 블록에는 return 문이 들어가지 못한다.
또한, init 블록 안에서 프로퍼티를 초기화하는 것도 가능하다.
class Person(fullName: String) {
val firstName: String
val familyName : String
init {
val names = fullName,split(" ")
if (names.size != 2) {
throw IllegalArgumentException("Invalid name: $fullName")
}
firstName = names[0]
familyName = names[1]
}
}
fun main() {
val person = Person("Dawn B")
println(person.firstName) // Dawn
}
컴파일러는 모든 프로퍼티가 확실히 초기화되는지 확인한다.
컴파일러가 주 생성자의 모든 실행 경로가 모든 멤버 프로퍼티가 초기화 되지 않으면 예외를 발생시킨다.
주 생성자 파라미터를 프로퍼티 초기화나 init 블록 밖에서 사용할 수는 없다.
예를 들어 멤버 함수 내부에서는 firstName을 사용할 수 없다.
class Person(firstName: String, familyName: String) {
val fullName = "$firstName $familyName"
fun printFirstName() {
println(firstName) // Error: firstName is not available here
}
}
이에 대한 해법은 생성자 파라미터의 값을 저장할 멤버 프로퍼티를 정의하는 것이다.
class Person(firstName: String, familyName: String) {
val firstName = firstName
val familyName = familyName
val fullName = "$firstName $familyName"
fun printFirstName() {
println(firstName) // 여기서 firstName은 멤버 프로퍼티를 가리킴
}
}
코틀린은 간단하게 생성자 파라미터의 값을 멤버 프로퍼티로 만들 수 있는 방법을 제공한다.
class Person(val firstName: String, val familyName: String) {
val fullName = "$firstName $familyName"
fun printFirstName() {
println(firstName) // 여기서 firstName은 멤버 프로퍼티를 가리킴
}
}
fun main() {
val person = Person("Dawn", "B")
println(person.firstName) // firstName은 프로퍼티를 가리킴
}
val / var 파라미터를 사용하면 단순하지 않은 멤버가 포함되지만 본문은 비어있는 클래스를 정의할 수 있다.
본문 생략 및 함수와 마찬가지로 디폴트 값도 사용할 수 있다.
class Person(val firstName: String, val familyName: String = "")
여러 생성자를 사용해 클래스 인스턴스를 서로 다른 방법으로 초기화하고 싶을 때도 있다.
대부분은 디폴트 파라미터를 사용하는 주 생성자로 해결할 수 있지만, 경우에 따라 주 생성자만으로는 충분하지 않을 수도 있다.
이런 경우, 부 생성자(secondary constructor)를 사용해 해결할 수 있다.
부 생성자는 함수 이름 대신에 constructor 키워드를 사용해야 한다.
class Person {
val firstName: String
val familyName : String
constructor(firstName: String, familyName: String) {
this.firstName = firstName
this.familyName = familyName
}
constructor(fullName: String) {
val names = fullNames.split(" ")
if (names.size != 2) {
throw IllegalArgumentException("Invalid name: $fullName")
}
firstName = names[0]
familyName = names[1]
}
}
부 생성자에 반환 타입을 지정할 수는 없지만, 기본적으로 부 생성자는 Unit 타입 값을 반환하는 함수와 마찬가지 형태다.
하지만 init 블록과 달리 부 생성자 안에서는 return을 사용할 수 있다.
클래스에 주 생성자를 선언하지 않은 경우, 모든 부 생성자는 자신의 본문을 실행하기 전에 프로퍼티 초기화와 init 블록을 실행한다.
이렇게 하면 어떤 부 생성자를 호출하든지 공통적인 초기화 코드가 정확히 한 번만 실행되게 보장할 수 있다.
다른 방법으로는 부 생성자가 생성자 위임 호출을 사용해 다른 부 생성자를 호출하는 것이 있다.
class Person {
val fullName: String
constructor(firstName: String, familyName: String):
this("$firstName $familyName")
constructor(fullName: String) {
this.fullName = fullName
}
}
생성자 파라미터 목록 뒤에 콜론(:)을 넣고그 뒤에 일반 함수를 호출하는 것처럼 코드를 작성하되, 함수 이름 대신 this를 사용하면 생성자 위임 호출이 된다.
클래스에 주 생성자가 있다면, (부 생성자가 있는 경우) 모든 부 생성자는 주 생성자에게 위임을 하거나 다른 부 생성자에게 위임을 해야 한다.
class Person(val fullName: String) {
constructor(firstName: String, familyName: String):
this("$firstName $familyName")
}
부 생성자의 파라미터 목록에는 val / var 키워드를 쓸 수 없다.
가시성은 클래스 멤버마다 다르게 지정할 수 있다. 즉, 각각 어떤 영역에서 쓰일 수 있는지 결정할 수 있다.
가시성을 사용해 구현과 관련한 세부 사항을 캡슐화함으로써 외부 코드로부터 구현 세부사항을 격리시킬 수 있으므로, 가시성 지정은 클래스 정의 시 아주 중요한 부분이다.
class Person(private val firstName: String, private val familyName: String) {
fun fullName() = "$firstName $familyName"
}
fun main() {
val person = Person("Dawn", "B")
println(person.firstName) // Error: can not access 'firstName': It is private in 'Person'
println(person.fullName()) // OK
}
주 생성자의 가시성을 지정하려면 constructor 키워드를 명시해야 한다.
class Empty private constructor() {
fun showMe() = println("Empty")
}
fun main() {
// Empty 클래스의 유일한 생성자가 private이므로 이 클래스를 클래스 본문 외부에서 인스턴스화할 수 없다.
Empty().showMe() // Error: can not access '<init>': it is private in 'Empty'
}
자바와 마찬가지로 코틀린의 참조 값에는 아무것도 참조하지 않는 경우를 나타내는 특별한 null(널)이라는 값이 있다.
자바에서는 모든 참조 타입의 변수에 널을 대입할 수 있기에 NullPointerException(NPE)가 발생할 가능성이 있다.
이 오류가 최악인 이유는 컴파일러가 정적인 타입 정보만으로는 이런 오류를 잡아낼 수 없어서 런타임에 프로그램을 실행해봐야 이 오류를 찾을 수 있기 때문이다.
코틀린 타입 시스템에는 널 값이 될 수 있는 참조 타입과 널 값이 될 수 없는 참조 타입을 확실히 구분해주는 큰 장점이 있다.
이 기능은 널 발생 여부를 컴파일 시점으로 옮겨주기 때문에 악명 높은 NPE 예외를 상당 부분 막을 수 있다.
자바와 달리 코틀린은 기본적으로 모든 참조 타입은 널이 될 수 없는 타입이다. 따라서 String 같은 타입에 null을 대입할 수 없다.
코틀린에서는 널이 될 수 도 있는 값을 설정하려면 타입 뒤에 물음표(?)를 붙여서 타입을 널이 될 수 있는 타입(nullable type)으로 지정해야 한다.
fun isLetterString(s: String?): Boolean {
if (s == null) return false // null은 여기서 걸러지기에 아래는 무조건 null이 아니라고 판단하고 진행된다.
if (s.isEmpty()) return false
for (ch in s) {
if (!ch.isLetter()) return false
}
return true
}
s 자체의 타입을 바꾸지는 않았지만 null에 대한 검사를 추가하여 널 가능성을 검사하면 컴파일된다.
스마트 캐스트(smart cast)라고 불리는 코틀린 기능이 이런 일을 가능하게 해준다.
2번 라인에서 null인 경우 false로 return 되어 끝나기 때문에 그 이후 문장은 결코 실행되지 않는다. 이런 방식으로 스마트 캐스트가 동작한다.
불변 지역 변수는 초기화 후 변경되지 않으므로 항상 제한 없이 스마트 캐스트를 쓸 수 있다.
하지만 널 검사와 사용 지점 사이에서 값이 변경되는 경우에는 스마트 캐스트가 작동하지 않는다.
따라서 가변 프로퍼티에 대해서는 절대 스마트 캐스트를 적용할 수 없다.
!! 연산자는 널 아님 단언(not-null assertion)이라고도 부른다.
참조 연산자를 사용할 때 null 여부를 컴파일시 확인하지 않도록 하여 런타임시 NPE가 발생하도록 의도적으로 방치한다.
fun main() {
println(readIntNotNull("1")) // 1
println(readIntNotNull(null)) // NPE
}
fun readIntNotNull(s: String?) = s!!.toInt()
?. 연산자는 안전한 호출 연산자(safe call)이다.
수신 객체가 널이 아닌 경우에는 의미 있는 일을 하고, 수신 객체가 널인 경우에는 널을 반환한다.
즉, 참조 연산자를 실행하기전에 먼저 객체가 null 인지 확인부터하고 객체가 null 이면 뒤따라오는 구문을 수행하지 않는다.
fun main() {
println(readIntSafeCall("1")) // 1
println(readIntSafeCall(null)) // null
}
fun readIntSafeCall(s: String?) = s?.toInt()
?: 연산자는 널 복한 연산자(null coalescing operator)이다. 엘비스 연산자라고도 부른다.
이 연산자를 사용하면 널을 대신할 디폴트 값을 지정할 수 있다.
즉, 객체가 null이 아니라면 그대로 사용하지만 null 이라면 연산자 우측의 객체로 대체된다.
class Name(val firstName: String, val familyName: String)
class Person(val name: Name?) {
fun describe(): String {
val currentName = name ?: return "Unknown"
return "${currentName.firstName} ${currentName.familyName}"
}
}
fun main() {
println(Person(Name("jake", "b")).describe()) // jake b
println(Person(null).describe()) // Unknown
}
프로퍼티는 어떤 클래스 인스턴스나 파일 퍼사드에 묶인 변수이며 자바 필드와 비슷하다.
하지만 일반적으로 코틀린 프로퍼티는 일반 변수를 넘어서, 프로퍼티 값을 읽거나 쓰는 법을 제어할 수 잇는 훨씬 더 다양한 기능을 제공한다.
어떤 프로퍼티는 클래스 인스턴스가 생성된 뒤에, 그러나 해당 프로퍼티가 사용되는 시점보다는 이전에 초기화돼야 할 수도 있다.
예를 들어 단위 테스트를 준비하는 코드나 의존 관계 주입에 의해 대입돼야 하는 프로퍼티가 이런 종류에 속한다.
이런 경우 생성자에서는 초기화되지 않은 상태라는 사실을 의미하는 디폴트 값(예를 들어 null)을 대입하고 실제 값을 필요할 때 대입할 수도 있다.
class Content {
var text: String? = null // 초기화되지 않은 상태를 의미하는 디폴트 값 설정
fun loadFile(file: File) {
text = file.readText() // 실제 사용 전에 항상 초기화
}
}
fun getContentSize(content: Content) = content.text?.length ?: 0 // 널 가능성 처리 필수
위 코드를 보면 실제 값이 항상 사용 전에 초기화되므로 절대 널이 될 수 없는 값이라는 사실을 알고 있음에도 불구하고 늘 널 가능성을 처리해야 한다.
이런 경우 lateinit 키워드를 적용하면, lateinit 표시가 붙은 프로퍼티는 값을 읽으려고 시도할 때 프로그램이 프로퍼티가 초기화됐는지 검사해서 초기화되지 않은 경우 UninitalizedPropertyAccessEception을 던진다.
class Content {
lateinit var text: String // 널 허용 하지 않아도 된다.
fun loadFile(file: File) {
text = file.readText() // 사용 전에 항상 초기화
}
}
fun getContentSize(content: Content) = content.text.length // 널 가능성 처리를 하지 않아도 된다.
프로퍼티를 lateinit으로 만들기 위해서는
프로퍼티를 처음 읽을 때까지 그 값에 대한 계산을 미뤄두고 싶은 경우 by lazy 프로퍼티를 통해 이를 달성할 수 있다.
val text by lazy {
"lazy init"
}
fun main() {
while (true) {
when (val command = readLine() ?: return) {
"print" -> println(text)
"exit" -> return
}
}
}
main() 함수에서 사용자가 적절한 명령으로 프로퍼티 값을 읽기 전까지, 프로그램은 lazy 프로퍼티의 값을 계산하지 않는다.
초기화가 된 이후 프로퍼티의 값은 필드에 저장되고, 그 이후로는 프로퍼티 값을 읽을 때마다 저장된 값을 읽게 된다.
디폴트로 lazy 프로퍼티는 스레드 안전(thread-safe)하다. 즉, 다중 스레드 환경에서도 값을 한 스레드 안에서만 계산하기 때문에 lazy 프로퍼티에 접근하려는 모든 스레드는 궁극저긍로 같은 값을 얻게 된다.
위임 프로퍼티에 대해서는 스마트 캐스트를 사용할 수 없다.
fun main() { val data by lazy { readLine() } if (data != null) { // error: smart cast to 'String' is impossible, because 'data' is a property that has open or cusom getter println("${data.length}") } }
커스텀 접근자는 프로퍼티 값을 읽거나 쓸 때 호출되는 특별한 함수다.
class Person(val firstName: String, val familyName: String) {
val fullName: String
get(): String { // getter의 반환 타입은 프로퍼티 타입(fullName의 String)과 같아야 한다. 또는 프로퍼티 반환 타입 생략 후 getter로 타입 추론도 가능하다.
return "$firstName $familyName"
}
// get() = "$firstName $familyName" 식이 본문인 형태고 사용 가능하다.
var age: Int? = null
set(value) {
if (value != null && value <= 0) {
throw IllegalArgumentException("Invalid age: $value")
}
field = value
}
}
fun main() {
val person = Person("jake", "b")
println("firstName: ${person.firstName}") // firstName: jake
println("familyName: ${person.familyName}") // familyName: b
println("fullName: ${person.fullName}") // fullName: jake b
println("age: ${person.age}") // age: null
person.age = 30
println("age: ${person.age}") // age: 30
}
게터에는 파라미터가 없다. 반면 게터의 반환 타입은 프로퍼티의 타입과 같아야 한다.
또는 프로퍼티의 타입을 생략하고 게터에 정의된 내용을 바탕으로 타입 추론에 의존해도 된다.
가변 프로퍼티에는 값을 읽이 위한 게터와 값을 설정하기 위한 세터도 가능하다.
프로퍼티 세터의 파라미터는 단 하나이며, 타입은 프로퍼티 자체의 타입과 같아야 한다. 보통은 파라미터 타입을 항상 미리 알 수 있기에 생략한다.
커스텀 게터가 있는 프로퍼티는 약간의 문법적인 차이에도 불구하고 파라미터가 없는 함수처럼 동작하므로, 어떤 경우 함수를 사용하고 어떤 경우 프로퍼티를 사용할지에 대한 의문이 떠오를 수 있다.
공식 코틀린 코딩 관습은 아래의 경우, 함수보다 프로퍼티를 권장한다.
- 값을 계산하는 과정에서 예외가 발생할 여지가 없는 경우
- 값을 계산하는 비용이 충분히 싸거나 캐시를 한 경우
- 클래스 인스턴스의 상태가 바뀌기 전에는 여러번 프로퍼티를 읽는 경우
- 함수를 호출해도 항상 똑같은 결과를 내는 경우
코틀린에서 객체 선언은 클래스와 상수를 합한 것이다.
객체 선언시 class 대신 object라는 키워드를 사용한다.
object는 흔히 자바나 안드로이드에서 사용하는 무명 내부 클래스처럼 사용 할 수 있다.
즉, 클래스를 정의하면서 객체를 생성한다.
또한, object 키워드를 이용해서 싱글턴 구현을 지원한다.
import Application.exit
object Application { // object class 생성으로 싱글턴 구혅
val name = "My App"
override fun toString() = name
fun exit() {}
}
println(Application.name) // 싱글턴처럼 .로 접근
exit() // import 후 이름 없이 사용 가능
객체 정의는 스레드 안전하다.
컴파일러는 실행되는 여러 스레드에서 싱글턴에 접근하더라도 오직 한 인스턴스만 공유되고 초기화 코드도 단 한 번만 실행되도록 보장한다.
초기화는 싱글턴 클래스가 실제 로딩되는 시점까지 지연된다. 보통은 프로그램이 객체 인스턴스에 처음 접근할 때 초기화가 이뤄진다.
자바에는 유틸리티 클래스(Utility Class)가 존재한다.
유틸리티 클래스는 기본적으로 아무 인스턴스를 갖지 않고 관련 정적 메서드를 모아두는 역할만 하는 클래스다.
하지만 코틀린에서는 권장되지 않는 패턴이다.
무엇보다 코틀린 클래스에서는 정적 메서드를 정의할 수 없기에(static이 없다) 자바와 동일한 방식으로 유틸리티 클래스를 정의할 방법이 없다.
object로 선언된 객체는 '최초 사용시' 자동으로 생성되며 이후에는 코드 전체에서 '공용으로 사용'될 수 있으므로 프로그램이 종료되기 전까지 공통적으로 사용할 내용들을 묶어 만드는 것이 좋다.
기존 클래스 내부에도 동반 객체(companion object)를 이용해서 object를 만들 수 있다.
클래스의 인스턴스 기능은 그대로 사용하면서 인스턴스간에 공용으로 사용할 속성 및 함수를 별도로 만드는 기능이다.
기능적으로는 자바의 static 멤버와 유사하다.
또한, 동반 객체는 팩토리 패턴을 구현하는데 효과적이다.
생성자를 비공개로 지정해 클래스 외부에서 사용할 수 없게 한 다음, 내포된 객체에 팩토리 메서드 역할을 하는 함수를 정의하고 그 함수 안에서 필요에 따라 객체의 생성자를 호출하는 것이다.
class Application private constructor(val name: String) {
object Factory {
fun create(args: Array<String>): Application? {
val name = args.firstOrNull() ?: return null
return Application(name)
}
}
}
fun main() {
var args = arrayOf("jake", "dawn", "roger")
val app = Application.Factory.create(args) ?: return
println("Application started: ${app.name}")
}
위 코드의 경우 별도로 import Application.Factory.create로 팩토리 메서드를 임포트하지 않는 한 매번 내포된 객체의 이름을 지정해야 한다.
코틀린은 동반 객체로 정의함으로써 이런 문제를 해결할 수 있다.
class Application private constructor(val name: String) {
companion object {
fun create(args: Array<String>): Application? {
val name = args.firstOrNull() ?: return null
return Application(name)
}
}
}
fun main() {
var args = arrayOf("jake", "dawn", "roger")
val app = Application.create(args) ?: return
println("Application started: ${app.name}")
}
companion object 키워드를 사용하고 동반 객체 정의시 이름을 생략할 수 있다.
실제 사용할 때도 Application.Factory.create() -> Application.create()로 간결해진다.
이런 방식을 권장한다.
동반 객체 이름을 생략한 경우 컴파일러는 동반 객체의 디폴트 이름을 Companion으로 가정한다.
즉, 동반 객체의 멤버를 임포트하고 싶을 때는 객체 이름을 Companion으로 명시해야 한다.import Application.Companion.create
또한, 클래스에는 동반 객체가 둘 이상 있을 수 없다.
코틀린의 동반 객체를 자바의 정적 문맥과 대응하는 것처럼 생각할 수도 있다. 자바의 정적 멤버와 마찬가지로 동반 객체의 멤버도 외부 클래스와 똑같은 전역 상태를 공유하며 외부 클래스의 모든 멤버에 멤버 가시성과 무관하게 접근할 수 있기 때문이다.
하지만 중요한 차이는 코틀린 동반 객체의 문맥은 객체 인스턴스라는 점이다. 이로 인해 자바의 정적 멤버보다 코틀린 동반 객체가 더 유연하다.
코틀린 동반 객체는 다른 상위 타입을 상속할 수도 있고 일반 객체처럼 여기저기에 전달될 수 있기 때문이다.
코틀린은 명시적인 선언 없이 객체를 바로 생성할 수 있는 특별한 식을 제공한다.
객체 식(object expression)은 자바 익명 클래스와 유사하다.
fun main() {
fun midPoint(xRange: IntRange, yRange: IntRange) = object {
val x = (xRange.first + xRange.last)/2
val y = (yRange.first + yRange.last)/2
}
val midPoint = midPoint(1..5, 2..6)
println("midPoint: ${midPoint.x}, ${midPoint.y}") // midPoint: 3, 4
}
객체 식은 함수뿐 아니라 프로퍼티에도 가능하다.
fun main() {
val o = object {
val x = 1
val y = 2
}
println(o.x + o.y)
}