가변성이 발생하는 코드는 프로그래밈을 이해하고 디버그하기가 힘들어지며 이러한 사앹를 갖는 부분들의 관계를 이해햐아 하기 때문에 상태 변경이 많아지면 이를 추적하는것이 힘들어 진다.
- 읽기 전용 프로퍼티 val
- 가변 컬렉션과 읽기 전용 컬렉션 구분하기
- 데이터 클래스의 copy
fun calculate(): Int {
println("Calculating...")
return 42
}
val fizz = calculate()
val buzz
get() = calculate()
@Test
internal fun `test 1`() {
println(fizz) // 42
println(fizz) // 42
println(buzz) // Calculating... 42
println(buzz) // Calculating... 42
}
값을 추출할 때 마다 사용자 정의 게터가 호출되므로 이러한 결과가 나옵니다.
interface Element {
val active: Boolean
}
class ActualElement : Element {
override var active: Boolean = false // val를 오버라이딩 하여 var로 변경이 가능하다 그러므로 불변으로 하고 싶다면 final 까지 사용하는 것이 좋다
}
val는 읽기 전용 프로퍼티지만, 변경할 수 없음을 의미하는 것은 아닙니다. 또한 게터 또는 델리게이트로 정의할 수 있습니다. 만약 완전히 변경할 필요가 없다면, final 프로퍼티를 사용하는 것이 좋습니다. val는 정의 옆에 상태가 바로 적히므로 코드의 실행을 예측하는 것이 훨씬 간단 합니다. 또한 스마트 캐스트 등 추가적인 기능을 활용할 수 있습니다.
val name: String? = "Marton"
val surname: String = "Braun"
val fullName: String?
get() = name?.let { "$it $surname" }
val fullName2: String? = name?.let { "$it $surname" }
@Test
internal fun `smart cast`() {
if (fullName != null) {
println(fullName.length) // 오류
}
if (fullName2 != null) {
println(fullName2.length) // 12
}
}
fullName은 게터로 정의했기 때문에 스마트 캐스트할 수 없습니다. 게터를 활용하므로, 값을 사용하는 시점에 name에 따라서 다른 결과가 나올 수 있기 때문입니다. fullName2 처럼 지역 변수가 아닌 프로퍼티가 final 이고 사용자 정의 게터를 갖지 않는 경우라면 스마트 캐스트할 수 있습니다.
코틀린은 읽고 쓸 수 있는 프로퍼티와 읽기 전용 프로퍼티로 구분됩니다. 마찬가지로 컬렉션도 읽고 쓸 수 있는 컬렉션과 읽기 전 컬렉션으로 구분됩니다. 이는 컬렉션 계층이 설계된 방식 덕입니다.
Mutavble이 붙은 틴터페이스는 대응되는 읽기 전용 인터페이스를 상속 받아서, 변경을 위한 메서드를 추가한 것입니다. 이는 마치 일긱 전용 프로퍼티가 게터만 갖고, 일고 쓰기 전용 프로퍼타가 게터와 셋터를 모두 가지던 것과 비슷하게 동작합니다.
개발자가 다운캐스팅을 할 때 문제가 됩니다. 읽기 전용으로 리턴하면 이를 읽기 전용으로만 사용해야 합니다. 이는 단순한 계약의 문제라고 할 수 있습니다.
@Test
internal fun `down casting 위반`() {
val list = listOf(1, 2, 3)
if (list is MutableList) {
list.add(1) // java.lang.UnsupportedOperationException 오류 발생
}
}
변경은 가능하지만 Arrays.ArrayList는 이러한 연산을 구현하고 있지 않아 위와 같은 오류가 발생합니다.
String이나, Int 처럼 내부적인 사앹를 변경하지 않는 immutable 객체를 많이 사용하는데 이유가 있습니다.
- 한 번 정의된 상태가 유지되므로, 코드를 이해하기 쉽습니다.
- immutable 객체는 공유했을 때도 충돌이 따로 이루어지지 않으므로, 병렬 처리를 안전하게 할 수 있습니다.
- immutable 객체에 대한 참조는 변경되지 않으므로, 쉽게 캐시할 수 있습니다.
- immutable 객체는 방어적 복사본을 만들 필요가 없습니다. 또한 객체를 복사할 때 깊은 복사를 따로 하지 않아도 됩니다.
- immutable 객체는 다른 객체를 만들 때 활용하기 좋습니다. immutable 객체는 실행을 더 쉽게 예측할 수 있습니다.
- immutable 객체는 set, map의 키로 사용할 수 있습니다. 이는 set, map은 내부적으로 해시 테이블을 사용하고, 해시 테이블은 처음 요소를 넣을 때 요소의 값을 기반으로 버킷을 결정하기 때문입니다.
@Test
fun `hash immutable`() {
val names: SortedSet<FullName> = TreeSet()
val person = FullName("AAA", "AAA")
names.add(person)
names.add(FullName("Jon", "Jon"))
names.add(FullName("David", "David"))
println(names) // [AAA AAA, Jon Jon, David]
println(person in names) // true
person.name = "ZZZ"
println(names) // [AAA ZZZ, Jon Jon, David]
println(person in names) // false
}
내부에 해당 객체가 있음에도 fale를 리턴합니다. 객체를 변경했기 때문에 찾을 수 없는 것입니다.
@Test
internal fun `none data`() {
class User(
val name: String,
val surname: String,
) {
fun withSurname(surname: String) = User(name, surname)
override fun toString(): String {
return "User(name='$name', surname='$surname')"
}
}
var user = User("Maja", "Markiewicz")
user = user.withSurname("Moskla")
println(user) // User(name='Maja', surname='Moskla')
}
withSurname 메서드 처럼 자신을 수정한 새로운 객체를 만들어야 합니다. 다만 모든 프로퍼티 대상으로 이런 함수를 하나하나 만드는 것은 굉장히 귀찮은 일입니다. 이런 경우 data 한정자를 사용하면 됩니다. data 한정자의 copy 메서드는 모든 기본 생성자 프로퍼티가 같은 새로운 객체를 만들어 낼 수 있습니다.
@Test
internal fun `data copy`() {
data class User(
val name: String,
val surname: String,
) {
fun withSurname(surname: String) = User(name, surname)
override fun toString(): String {
return "User(name='$name', surname='$surname')"
}
}
var user = User("Maja", "Markiewicz")
user = user.copy(surname = "Moskla")
println(user) // User(name='Maja', surname='Moskla')
}
변경을 할 수있다는 측면만 보면 mutable 객체가 더 좋아 보이지만, 이렇게 데이터 모델 클래스를 만들어 immutable 객체를 만드는 것이 더 많은 장점을 갖습니다.
@Test
internal fun `방어 로직`() {
data class User(val name: String)
class UserRepository {
private val storedUsers: MutableMap<Int, String> = mutableMapOf()
fun loadAll(): Map<Int, String> {
return storedUsers
}
}
val userRepository = UserRepository()
val storedUsers = userRepository.loadAll()
storedUsers[4] = "Kirill" // 컴파일 오류
}
객체를 읽기 전용 슈퍼타입으러 업캐스트하여 가변셩을 제한 할 수 있습니다.
@Test
internal fun `변경 가능 지점 노출하지 말기`() {
data class User(val name: String)
class UserRepository {
private val storedUsers: MutableMap<Int, String> = mutableMapOf()
fun loadAll(): MutableMap<Int, String> {
return storedUsers
}
}
val userRepository = UserRepository()
val storedUsers = userRepository.loadAll()
storedUsers[4] = "Kirill"
}
loadAll을 사용해서 private 상태인 UserRepositroy를 수정할 수 있습니다.
- 프로퍼티보다는 지역 변수를 사용하는 것이 좋습니다.
- 최대한 좁은 스코프를 갖게 변수를 사용 합니다.
@Test
internal fun `변수의 스코프를 최소화하라`(users: List<User>) {
// 나쁜 예
var user: User
for (index in users.indices) {
user = users[index]
println("User at $index is $user")
}
// 조금 더 좋은 예
for (index in users.indices) {
val user = users[index]
println("User at $index is $user")
}
// 제일 좋은 예
for ((index, user) in users.withIndex()) {
println("User at $index is $user")
}
}
@Test
internal fun `구조 분해 선언`() {
// 나쁜 예
val user: User
if (true) {
user = getValue()
} else {
user = User()
}
// 좋은 예
val user: User = if (true) {
getValue()
} else {
User()
}
}
플랫폼 타입은 String! 처럼 타입 이름 뒤에 ! 기호를 붙여서 표시합니다.
public class UserRepo {
public User getUser() {
// ...
}
}
// kotlin
val repo = UserRepo()
val user1 = repo.user // user1 타입은 User!
val user2: User = repo.user // user2 타입은 User
val user3: User? = repo.user // user3 타입은 User?
null일 가능성이 있으므로, 여전히 위험 함니다. 그래서 플랫폼 타입을 사용할 때는 항상 주의 해야합니다. 설계자가 명시적으로 어노테이션으로 표시하거나, 주석으로 달아두지 않으면, 언제든지 동작이 변경될 가능성이 있습니다.
import org.jetbrtains.annotations.NotNull;
public class UserRepo {
public @NotNull User getUser() {
// ...
}
}
@NotNull
를 사용하여 null를 리턴하지 않는다는 것을 표시합니다.
inferred 타입은 정확하게 오른쪽에 있는 피연산자에 맞게 설정된다는 것을 기억해야 합니다. 절대로 슈퍼 클래스 또는 인터페이스로 설정되지 않습니다.
@Test
internal fun `아이템 4 inferred 타입으로 리턴하지 말라`() {
open class Animal
class Zebra : Animal()
var animal = Zebra()
animal = Animal() // 오류: type mismatch
var animal: Animal = Zebra() // 슈퍼 클래래스, 또는 인터페이스로 설정하지 않고 정확하게 피연산자에 맞게 설정
animal = Animal() // 오류 없음
}
- require 블록: 아규먼트를 제한할 수 있습니다.
- check 블록: 상태와 관련된 동작을 제한할 수 있습니다.
- assert 블록: 어떤 것이 true인지 확인할 수 있습니다. assert 블록은 테스트 모드에서만 동작합니다.
- return 또는 throw와 함께 활용하는 Elvis 연산자
표준 라이브러리에 있는 예외를 활용하고 적절한 오류가 없으면 사용자 정의 오류를 정의해서 사용하는 것아 바람직합니다.
- 서버로부터 데이터를 읽어 들이려고 했는데, 인터넷 연결 문제로 읽어 들이지 못하는 경우
- 조건에 맞는 첫번쨰 요소를 찾으려 했는데, 조건에 맞는 요소가 없는 경우
- 텍스트를 파싱해서 객체를 만들려고 했는데, 텍스트의 형식이 맞지 않는 경우
이러한 상황을 처리하는 메커니즘은 크게 다음과 같이 두가지가 있습니다.
- null 또는 실패를 나타내는 sealed 클래스(일반적으로 Failure 라는 이름을 붙입니다.)를 리턴한다.
- 예외를 throw 한다.
우선 예외는 정보를 전달하는 아법으로 사용해서는 안됩니다. 예외는 잘못된 특별한 상황을 나타내야 하며 처리되어야 합니다. 예외는 예외적인 상황이 발생했을 때 사용하는 것이 좋습니다. 이유는 다음과 같습니다.
- 많은 개발자는 예외가 전파되는 과정을 제대로 추적하지 못합니다.
- 코틀린의 모든 예외는 unchecked 예외입니다. 따라서 사용자가 예외를 처리하지 않을 수도 있으며, 이와 관련된 내용은 문서에도 제대로 드러나지 않습니다.
- 예외는 예외적인 상황을 처리하기 위해서 만들어졌으므로 명시적인 테스트만큼 빠르게 동작하지 않습니다.
- try-catch 블록 내부에 코드를 배치하면, 컴파일러가 할 수이쓴ㄴ 최적화가 제한됩니다.
반면, 첫 번쨰로 설명했단 null과 Failure는 예상되는 오류를 표현할 때 굉장히 좋습니다. 명시적으로 효율적이며, 간단한 방법으로 처리할 수 있습니다. 따라서 충분히 예측할 수 있는 범위 오류는 null가, Failure를 사용하고 예측 하기 어려운 예외적인 오유는 예외를 throw 해서 처리하는 것이 좋습니다.
inline fun <reified T> String.readObjectOrNull(): T? {
if (...) {
return null
}
return result
}
inline fun <reified T> String.readObject(): Result<T> {
if (...){
return Failure(JsonParsingException())
}
return Success(result)
}
sealed class Result<out T>
class Success<out T>(val result: T) : Result<T>()
class Failure(val throwable: Throwable) : Result<Nothing>()
class JsonParsingException : Exception()
// readObjectOrNull 처리 방법
val age = userText.readObjectOrNull<Person>()?.age ?: -1
// readObject 처리 방법
val person = userText.readObjectOrNull<Person>()
val age = when (person) {
is Success -> person.age
is Failure -> -1
}
프로퍼티가 반드시 초기화 해야하는 경우 사용합니다. lateinit 한정자는 프로피티가 이후에 설정될 것임을 명시한 한정자입니다.
data class UserControllerTest {
private lateinit var dao: UserDao
private lateinit var controller: UserController
@BeforeEach
fun init() {
dao = mockk()
controller = UserController(dao)
}
@Test
internal fun test() {
controller.doSometing()
}
}
lateinit는 nullable과 비교해서 다음과 같은 차이가 있습니다.
- !! 연산자로 언팩 하지 않아도 됩니다.
- 이후에 어떤 의미를 나타내기 위해서 null을 사용하고 싶을 때, nullable로 만들 수 있습니다.
- 프로퍼티가 초기화 된 이후에는 초기화 되지 않은 상태로 돌아갈 수 없습니다.
lateinit를 사용할 수 없는 경우도 있습니다. JVM에서 Int, Long, Double, Boolean과 같은 기본 타입과 연결된 타입으로 프로퍼티를 초기화해야 하는 경우입니다. 이런 경우 lateinit 보다는 약간 느리지만 Delegates.notNull을 사용합니다.
더 이상 필요하지 않을 때, close 메서드를 사용해서 명시적으로 닫아야하는 리소스가 있습니다.
- InputStream, OutputSteam
- java.sql.Connection
- java.io.Reader
- java.new.Socket
이러한 모든 리소스는 최종적으로 리소스에 대한 레퍼런스가 없어질 때, 가비지 컬렉터가 처리합니다. 하지만 광장히 느리며 그동안 리소스를 유지하는 비용이 많이 들어갑니다. 따라서 더 이상 사용하지 않는다명 명시적으로 close를 호출해 사용하는 것이 좋습니다. 전통적으로 이러한 리소스는 다음과 같이 try-finally 블록을 사용해서 처리했습니다.
하지만 이런 방식은 광장히 복잡하고 좋지 않습니다. 리소스를 닫을 때 예외가 발생할 수도 있는데, 이러한 예외를 따로 처리하지 않기 때문에 try finally 블록 냐부애소 오류가 발생한다면 둘 중 하나만 전파됩니다. 둘다 전파가 될수 있도록 하면 좋지만 이를 직접 구현하려면 코드가 굉장히 길고 복잡해집니다. 그래서 이러한 표준 라이브러리에는 use라는 이름의 함수로 포함되어 있습니다. 이러한 코드는 모두 Closeable 객체에서 사용할 수 있습니다.
fun countCharactersInFile(path: String): Int {
BufferedReader(FileReader(path)).use { reader ->
return reader.lineSequence().sumOf { it.length }
}
}
파일을 리소스로 사용하는 경우가 적고, 파일을 한 줄씩 일겅 들이는 경우에는 useLines 함수도 제공합니다.
fun countCharactersInFile(path: String): Int {
BufferedReader(FileReader(path)).use { reader ->
return reader.lineSequence().sumOf { it.length }
}
}
이렇게 처리하면 메모리에 파일의 내용을 한 줄 씩만 유지하므로, 대용량 파일도 적절하게 처리할 수 있습니다. 다만 파일의 줄을 한 번만 사용할 수 있다는 단점도 있습니다. 파일의 특정 줄을 두 번 이상 반복 처리할 필요가 없다면 좋은 대안이 됩니다.
..
- 이름을 기반으로 값이 무엇을 나타내는지 알 수 있습니다.
- 파라미터 입력 순서와 상관 없으므로 안전합니다.
프로피터가 디폴트 아규먼트를 가질 경우, 항상 이름을 붙여 사용하는 것이 좋습니다. 일반적으로 함수 이름은 필수 파라미터들과 관련돠어 있기 때문애 이플토 값을 갖는 옵션 파라미터의 설명이 명확하지 않습니다. 따라서 이런 경우에는 이름을 붙여서 사용하는 것이 좋습니다.
파라미터가 모든 다른 타입이라면, 위치를 잘못 입력하면 오류가 발생할 것이기 때문에 쉽게 문제를 발견할 수 있는데 파라미터 타입이 같다면 잘못 입력하는 경우 문제를 찾기 어려울 수 있습니다. 이런 경우 이름 있는 아규먼트를 사용하는 것이 좋습니다.
일반적으로 함수 타입 파라미터는 마지막 위치에 배치하는 것이 좋습니다.
프로포터이 위임을 사용하면 일반적인 프로퍼티의 행위를 추출해서 재사용할 수 있습니다. 대표적으로 lazy 프로퍼티는 이후에 ㅓ음 사용하는 요청이 들아올 때 초기화되는 프로퍼티를 의미합니다. 코틀린에서서는 프로퍼티 위음을 활용해 간단하게 구현할 수 있습니다.
val value by lazy { createValue() }
변화가 있을 때 이를 감지하는 observable 패턴을 쉽게 만들 수 있다. 목록을 출력하는 리스트 어댑터가 있다면, 내부 데이터가 변경될 때마다 변경된 내용을 다시 출력하는 경우
var item: List<Item> by
Delegates.observable(listOf()) { _, _, _ ->
notifiDataSetChanged()
}
class AAA {
var token: String? = null
get() {
println("token returned value $field")
return field
}
set(value) {
println("token changed from $field to $value")
field = value
}
}
class BBB {
var token: String? by LoggingProperty(null)
var attempts: Int by LoggingProperty(0)
}
@Test
fun `None delegate`() {
val aaa = AAA()
aaa.token = "123"
aaa.token
}
@Test
fun `delegate`() {
val bbb = BBB()
bbb.token = "123"
bbb.token
}
프로퍼티 위임은 다른 객체에서 메서드를 활용해 프로퍼티의 접근자를 만드는 방식입니다. 이때 다른 객체의 메서드 이름이 중요한데. 게터는 getValue, 세터는 setValue 함수를 만들어 사용합니다.
class CCC(
email: String
) {
var email: String by Delegates.vetoable(email) { _, oldValue, newValue ->
val b = oldValue == newValue
when {
b -> println("동일한 값 데이터베이스 반영 없음")
else -> println("동일한 값 데이터베이스 반영")
}
return@vetoable b
}
}
@Test
fun `vetoable`() {
val ccc = CCC("123@asd.com")
ccc.email = "123@asd.com"
println(ccc.email)
ccc.email = "new@asd.com"
println(ccc.email)
}
코틀린 stdlib에서 다음과 같은 프로퍼티 델리게이터를 알아두면 좋습니다.
- lazy
- Delegates.observable
- Delegates.vetoable
- Delegates.notnull
타입 아규먼트를 사용하는 함수, 즉 타입 파라미터를 갖는 함수를 제네릭 함수라고 부른다. 타입 파라미터는 컴파일러에 타입과 관련된 정보를 제공하여 컴파일러가 타입을 조금이라도 더 정확하게 추측할 수 있게 해줍니다. 따라서 플그램이 조금 더 안전해지고, 프로그래밍이 편해집니다.
예를 들어 T를 Iterable의 서브타입으로 제한하면, T 타입을 기반으로 반복 처리가 가능하고, 반복 처리 때 사용하는 객체가 Int라는 것을 알 수 있씁니다.
타입 파라미터의 중요한 기능 중 하는 구채적인 타입의 서브타입만 사용하게 타입을 제한 한다는 것입니다.
프로퍼티와 파라미터가 같은 이름을 가질 수 있씁니다. 이렇게되면 지역 파라미터가 외부 스코프에 있는 프로퍼티를 가립니다. 이를 섀도잉이라고 부릅니다.
class Forest(var nane: Stirng) {
fun addTree(name: String) {
// ...
}
}
이러한 섀도잉 현상은 클래스 타입 파라미터와 함수 타입 파라미터 사이에도 발생합니다.
interface Tree
class Birch : Tree
class Spruce : Tree
class Forest<T : Tree> {
// (1)
fun <T : Tree> addTree(tree: T) {
// ...
}
// (2)
fun addTree(tree: T) {
// ...
}
}
val forest = Forest<Birch>
forest.addTree(Birch())
forest.addTree(Spruce()) //(1) 경우 ERROR, type mismatch
이렇게 작성하면 Forest와 addTree의 타입 파라미터가 독립적 동작합니다. 만약 독립적인 타입 파라미터를 의도했다면. 이름을 아예 다르게 하는 것이 좋습니다.
class Forest<T : Tree> {
fun <ST : Tree> addTree(tree: ST) {
// ...
}
}
위 코드 처럼 타입 파라미터를 사용해 다른 타입 파라미터에 제한을 줄 수도 있습니다.
class Cup<T>
타입 파라미터는 T variance 한정자(out, in)가 없으므로, 기본적으로 invariant(불공변셩)타입 입니다. invariant라는 것은 제네릭 타입으로 만들어진 타입이 서로 관련성이 없다는 의미입니다. 예를 들어 Cup와 Cup, Cup, Cup은 어떠한 관련성도 갖지 않습니다.
fun type() {
val anys: Cup<Any> = Cup<Int>() // 오류 : Type mismatch
val nothings: Cup<Nothing> = Cup<Int>() // 오류
}
만약 어떤 관련성을 원한다면 out, in 이라는 variance 한정자를 붙입니다. out은 타입 파라미터를 covariant(공변성)로 만듭니다. 이는 A가 B의 서브타입일 때 Cup<A>
가 Cub<B>
의 서브타입이라는 의미입니다.
class Cup<out T>
open class Dog
class Puppy : Dog()
fun type() {
val a: Cup<Dog> = Cup<Puppy>() // OK
val b: Cup<Puppy> = Cup<Dog>() // 오류
val anys: Cup<Any> = Cup<Int>() // OK
val nothings: Cup<Nothing> = Cup<Int>() // 오류
}
in 한정자는 반대 의미입니다. in 한정자는 타입 파라미터를 contravariant(반변성)으로 만듭니다. 이는 A가 B의 서브타입일때, Cup<A>
가 Cup<B>
의 슈퍼타입이라는 것을 의미합니다.
class Cup<in T>
open class Dog
class Puppy : Dog()
fun type() {
val a: Cup<Dog> = Cup<Puppy>() // 오류
val b: Cup<Puppy> = Cup<Dog>() // OK
val anys: Cup<Any> = Cup<Int>() // 오류
val nothings: Cup<Nothing> = Cup<Int>() // OK
}
예를 들어 Int를 받고, Any를 리턴하는 함수를 파라미터로 받는 함수를 생각할 때
fun printProcessedNumber(transition: (Int) -> Any) {
print(transition(42))
}
(Int)-> Any 타입의 함수는 (Int)->Number, (Number)-> Any, (Number)->Number, (Number)-> Int 등으로 동작합니다.
코틀린 함수 타입의 모든 파라미터 타입은 contravariant입니다. 또한 모든 리턴 타입은 covariant입니다.
(T 1 in, T 2 in) -> T out
in = contravariant
out = covariant
함수 타입을 사용할 때는 이처럼 자동으로 variance 한정자가 사용됩니다. 코틀린에서 자주 사용되는 것으로 covariant(out 힌정자)를 가진 List가 있습니디.
자바 배열은 covariant입니다. 이는 배열을 기반으로 제네릭 연산자는 정렬 함수 등을 만들기 위해서입니다. 그런데 자바 배열ㅇ; covariant라는 속서을 갖기 때문에 큰 문제가 발생합니다. 컴파일 도중에는 아무런 문제가 없지만, 런타임 오류가 발생합니다.
Integer[]numbers={1,4,2,1};
Object[]objects=numbers;
objects[2]="B" // 런타임 오류: ArrayStoreException
numbers를 Object[]로 캐스팅해 구조 내부에 사옹되고 있는 실질적인 타입이 바뀌는 것은 아닙니다. 따라서 이러한 배열에 String 타입의 갓을 할당하면, 오류가 발생합니다. 이는 자바의 명백한 결합입니다. 코틀리는 이러한 결함을 해결 하기 위해 Array(IntArray, CharArray 등)를 invariant로 만들었습니다. 따라서 Array, Array 등으로 바꿀수 없습니다.
파라미터 타입을 예측할 수 있다면, 어떤 서브타입이라도 전달할 수 있습니다. 따라서 아규먼트를 전달할 때, 암묵적으로 업캐스팅할 수 있습니다.
open class Dog
class Puppy : Dog()
class Hound : Dog()
fun takeDog(dog: Dog) {}
takeDog(Dog())
takeDog(Puppy())
takeDog(Hound())
이는 covariant하지 않습니다. covariant 타입 파라미터(out 한정자)가 in 한정 위치(예를 들어 타입 파라미터)에 있다면, covariant와 업캐스팅을 연결해서, 우리가 원하는 타입을 아무것이나 전달 할 수 있습니다. 즉 value가 매우 구체적인 타입이라 안전하지 않으므로 value를 Dog 타입으로 지정할 경우, String 타입을 넣을 수 없습니다.
class Box<out T> {
var value: T? = null
// 코틀린에서는 사용할 수 없는 코드
fun set(value: T) {
this.value = value
}
fun get(): T = value ?: error("Value not set")
}
val puppyBox = Box<Puppy>()
val dogBox = Box<Dog> = puppyBox
dogBox.set(Hound()) // 하지만 Puppy를 위한 공간 입니다.
val dogHouse = Box<Dog>()
val dogBox: Box<Dog> = puppyBox
dogBox.set(Hound()) // 하지만 Dog를 위한 공간입니다.
dogBox.set(42) // 하지만 Dog를 위한 공간입니다.
이러한 상황은 안전하지 않습니다. 캐스팅 후에 실질적인 객체가 그대로 유지되고, 타이핑 시스템에서만 다르게 처리되기 때문입니다. Int를 설정하려고 하는데, 해당 위치는 Dog만을 위한 자리입니다. 만약 이것이 가능하다면, 오류가 발생할 것입니다. 그래서 코틀린은 public in 한정자 위치에 covariant 타입과 파라미터(out 한정자)가 오는 것을 짐지하여 이러한 상활을 막습니다.
class Box<out T> {
private var value: T? = null // 오류
fun set(value: T) { // 오류
this.value = value
}
fun get(): T = value ?: error("Value not set")
}
가시성을 private로 제한한다면, 오류가 발생하지 않습니다. 객체 내부에서는 업캐스트 객체에 covariant(out 한정자)를 사용할 수 없기 때문입니다.
지시자 | 설명 |
---|---|
public | 어디서나 접근 가능 |
private | 클래스 내부에서만 접근 가능가능 |
protected | 클래스와 서브 클래스 내부에서만 접근 가능 |
internal | 모듈 내부에서만 접근 가능 |
class Employee {
private val id: Int = 2
override fun toString(): String {
return "Employee(id=$id)"
}
private fun privateFunction() {
println("Private function called")
}
@Test
fun `call private function`() {
val employee = Employee()
callPrivateFunction(employee)
changeEmployeeId(employee, 123)
println(employee)
}
}
fun callPrivateFunction(employee: Employee) {
employee::class.declaredMemberFunctions
.first { it.name == "privateFunction" }
.apply { isAccessible = true }
.call(employee)
}
fun changeEmployeeId(employee: Employee, newId: Int){
employee::class.java.getDeclaredField("id")
.apply { isAccessible = true }
.set(employee, newId)
}
private 프로퍼티, 메서드를 이름에 크게 의존하고 있습니다. 이는 내부적인 정보로 언제든지 변경될수 있기 때문에 이런 코트를 프로젝트에서 사용한다면 변경시 사이드이펙트가 발생할 수 있는 요소가 높습니다. 이렇게 규약을 위반하면 문제가 발생합니다.
코틀린에서는 인터페이스를 통해서도 구현을 강제 할 수 있습니다.
interface MyList<T> {
companion object {
fun <T> of(vararg elements: T): MyList<T>? {
//...
return null
}
}
}
class MyLinkedList<T>(
val head: T,
val tail: MyLinkedList<T>?,
) : MyList<T>{
// ...
}
fun `of test`() {
MyList.of(123)
}
다음과 같은 이름을 많이 사용합니다.
- from: 파라미터를 하나 받고, 같은 타임의 인스턴스를 하나 리턴하는 타입 변환 함수를 나타냅니다.
- of: 파라미터를 여러개 받고, 이를 통합해서 인스턴스를 만들어 주는 함수를 나타냅니다.
- valueOf: of와 비슷한 기능을 하면서도, 그 의미를 조금 더 쉽게 읽을 수 있게 이름을 붙인 함수 입니다.
- instance, getInstance: 싱글톤 인스턴스 하나를 리턴하는 함수입니다. 파라미터가 있는 경우, 아규먼트를 기반으로 하는 인스턴스를 리턴합니다. 일반적으로 같은 아규먼트를 넣으면, 같은 인스턴스를 리턴하는 형태로 동작합니다.
- createInstance 또는 newInstance: getInstance 싱글턴이 적용 되지 않아, 함수를 호출할 때마다 새로운 인스턴스를 만들어서 리턴합니다.
- getType: getInstance 처럼 동작 하지만, 팩토리 클래스가 다른 클래스에 있을 때 사용하는 이름입니다.
- newType: newInstance 처럼 동작 하지만, 팩토리 클래스가 다른 클래스에 있을 때 사용하는 이름입니다.
copy는 immutable 데이터 클래스를 만들 때 편리합니다. copy는 기본 생성자 프로퍼티가 같은 새로운 객체를 복제합니다. 새로 만들어진 객체의 값은 이름이 있는 아규먼트를 통해 변경할 수 있습니다.
data class Player(
val id: Int,
val name: String,
val pints: Int,
)
@Test
fun `copy fun`() {
val player = Player(
id = 1,
name = "Jin",
pints = 150,
)
val copyPlayer = player.copy(name = "Kean")
// Player(id=1, name=Kean, pints=150)
println(copyPlayer)
}
이러한 copy 메서드는 data 한정자를 붙이기만 하면 자동으로 만들어 집니다.
compoentN 함수는 위치를 기반으로 객체를 해제할 수 있게 해줍니다.
@Test
fun `componentN fun`() {
val player = Player(
id = 1,
name = "Jin",
pints = 150,
)
val (id, name, pts) = player
val payerId = id
val payerName = name
val payerPoints = pts
println("payerId: $payerId")
println("payerName: $payerName")
println("payerPoints: $payerPoints")
}
이렇게 위치 기반으로 객체를 해제하는 것도 가능하며, componentN 함수는 List, Map.Entry 등의 형토로도 객체를 해제할 수 있습니다.
@Test
fun `componentN fun2`() {
val visited = listOf("China", "Russia", "India")
val (first, second, third) = visited
// China, Russia, India
println("$first, $second, $third")
val trip = mapOf(
"China" to "Tianjin",
"Russia" to "Petersburg",
"India" to "Rishikesh",
)
for ((country, city) in trip) {
// We loved Tianjin in China
// We loved Petersburg in Russia
// We loved Rishikesh in India
println("We loved $city in $country")
}
}
하지만 위치를 잘못 지정하면, 다양한 문제가 발생할 수 있어서 위험합니다. 객체를 해제할 때는 주의해야 하므로 데이터 클래스의 기본 생성자에 붙어 있는 프로퍼티 이름과 같은 이름을 사용하는 것이 좋습니다. 그렇게하면 순서 등을 잘못 지정했을 때, 인텔리제이에서 경고를 줍니다.
sealed 한정자는 외부 파일에서 서브클래스를 만드는 행위 자체를 모두 제한합니다. 외부에서 추가적인 서브클래스를 만들 수 없으므로, 타입이 추가되지 않을 거라는게 보장됩니다. 따라서 when을 사용할 때 else 브랜치를 따로 만들필요가 없습니다. 이러한 장점으로 새로운 기능을 쉽게 추가할 수 있으며, when 구문에서 ㅇ이를 처리하는 것을 잊어버리지 않을 수도 있습니다.
JVM에서는 하나의 가상 머신에서 동일한 문자열을 처리하는 코드가 여러개 있다면, 기존의 문자열을 재사용 합니다.
val str1= "Test Sample"
val str2= "Test Sample"
pirnt(str1 == str2) // true
pirnt(str1 === str2) // true
Integer와 Long처럼 박싱화한 기본 자료형도 작은 경우에는 재사용 합니다. Int는 기본적으로 -128 ~ 127 범위를 캐시해 둡니다.
val i1: Int?= 1
val i2: Int?= 1
pirnt(i1 == i2) // true
pirnt(i1 === i2) // true
-128 ~ 127 범위를 벗어나느 숫자는 캐시되지 않습니다. 그래서 다음 코드는 false가 뜹니다.
val i1: Int?= 1234
val i2: Int?= 1234
pirnt(i1 == i2) // true
pirnt(i1 === i2) // false
참고로, nullable 타입은 int 자료형 대신 Integer 자료형을 사용하게 강제됩니다. int를 사용하면 일반적으로 기본 자료형 Int로 컴파일됩니다. 하지만 nullable로 만들거나, 타입 아규먼트로 사용할 경우에는 Integer로 컴파일됩니다. 기본 자료형은 null일 수 없고, 타입 아규먼트로 사용할 수 없기 때문입니다.
어떤 객체를 wrap 하면 크게 세 가지 비용이 발생합니다.
- 객체는 더 많은 용량을 차지합니다. 현재 64비트 JDK에서는 객체를 8바이트의 배수만큼 공간을 차지합니다. 앞부분 12바이트는 헤더로서 반드시 있어야 하므로, 최소 크기는 16 바이트입니다. 참고로 32비트 JVM에서는 8바이트 입니다. 추가로 객체에 대한 레퍼런스도 공간을 차지합니다. 일반적으로 레퍼런스는 -Xmx32G까지는 32비트 플랫폼과 64비트 플랫폼 모두 4바이트입니다. 또한 64비트 플랫폼에서 32ㅎ(-Xmx32G)부터는 8바이트입니다. 큰 공간은 아니지만, 분명히 비용적으로 추가됩니다. 정수처럼 작은 것들은 많이 사용하면, 그 비용의 차이가 더 커집니다. 기본 자료형은 int 4바이트지만, 오늘날 널리 사용되고 있는 64비트 JDK 랩되어 있는 Integer는 16바이트 입니다. 추가로 이에 대한 레퍼런스로 인해 8비트가 더 필요합니다. 따라서 5배 이상의 공간을 차지한다고 할 수 있습니다.
- 요소가 캡슐화되어 있다면, 접근에 추가적인 함수 호출이 필요합니다. 함수를 사용하는 처리는 광장히 빠르므로 차찬가지로 큰 비용이 발생하지 않습니다. 하지만 티끌 모아 태산이 되는 것처럼 수많은 객체를 처리한다면 이 비용도 광장히 커집니다.
- 객체는 생성되어야 합니다. 객체는 생성되고, 메모리 영역에 할당되고, 이에대한 레퍼런스를 만드는 등의 작업이 필요합니다. 마찬가지로 적은 비용이지만, 모이면 광장히 큰 비용이 됩니다.
무거운 클래스를 만들 때는 지연되게 만드느 것이 좋을 때가 있습니다.
class A {
val b = B()
val c = C()
val d = D()
// ...
}
내부에 있는 인스턴스들을 지연 초기화하려면, A라는 객체를 생성하는 과정을 가볍게 만들 수 있습니다.
class A {
val b by lazy { B() }
val c by lazy { C() }
val d by lazy { D() }
// ...
}
다만 이러한 지연 초기화는 장점도 있지만 단점도 갖고 있습니다. 클래스가 무거운 객체를 가졌지만, 메서드의 호출을 빨라야 하는 경우가 있을 수 있습니다.
JVM은 숫자와 문자 등의 기본적인 요소를 나타내기 위한 특별한 기본 내장 자료형을 갖고 있습니다. 이를 기본 자료형이라고 합니다. 코틀린은/JVM 컴파일러는 내부적으로 최대한 이러한 자료형을 사용합니다. 다만 다음과 같은 두 가지 상황에서는 기본 자료형을 랩한 자료형이 사용됩니다.
- nullable 타입을 연산할 때 (기본 자료형은 null일 수 없으므로)
- 타입을 제네릭으로 사용할때
코틀린의 자료형 | 자바의 자료형 |
---|---|
Int | int |
Int? | Integer |
List | List |
이를 알면 랩한 자료형 대신 기본 자료형을 사용하게 코드를 최적화할 수 있습니다. 참고로 이러한 최적화는 코틀린/JVM, 일부 코틀린/Native 버전에서만 의미가 있으며, 코틀린/JS에서는 아무런 의미가 없습니다.
코틀린 표준 라이브러이의 고차 함수들은 대부분 inline 한정자가 붙어 있는 것을 확인할 수 있습니다. inline 한정자의 역할은 컴파일 시점에 함수를 호출하는 부분을 함수의 본문으로 대체하는 것입니다. 예를 들어 다음과 같이 repeat 함수를 호출하는 코드가 있다면
repeat(10) {
print(it)
}
컴파일 시점에 다음과 같이 대체됩니다.
for (index in 0 until 10) {
pirnt(index)
}
이 처럼 inline 한정자를 붙여 함수를 만들면, 광장히 큰 변화가 일어납니다. 일반적인 함수를 호출하면 함수 본문으로 점프하고, 본문의 모든 문장을 호출뒤에 함수를 호출 했던 위치로 다시 점프하는 과정을 거칩니다. 하지만 함수를 호출하는 ㅂ부분을 함수의 본문으로 데ㅔ하면 이러한 점프가 일어나지 않습니다.
inline 한정자를 사용하면 다음과 같은 장점이 있습니다.
- 타입 아규먼트에 reified 한정자를 붙여서 사용할 수 있습니다.
- 함수 타입 파라미터를 가진 함수가 훨씬 빠르게 동작합니다.
- 비지역(non-local) 리턴을 사용할 수 있습니다.
모든 컬렉션 처리 메서드는 비용이 많이 듭니다. 표준 컬렉션 처리는 내부적으로 요소들을 활용해 반복으로 돌며, 내부적으로 계산을 위해 추가적은 컬렉션을 만들어 사용 합니다. 시퀀스 처리도 시퀀스 전체를 랩하는 객체가 만들어지며 조작을 위해서 다른 또 다른 추가적인 객체를 만들어 냅니다. 따라서 적절한 메서드를 활용해 컬렉션 처리 단계 수를 적절하게 제한하는 것이 좋습니다.
data class Student(val name: String?)
// 작동합니다.
fun List<Student>.getNames(): List<String> = this
.map { it.name }
.filter { it != null }
.map { it!! }
// 더 좋습니다.
fun List<Student>.getNames(): List<String> = this
.map { it.name }
.filterNotNull()
// 가장 좋습니다.
fun List<Student>.getNames(): List<String> = this
.mapNotNull { it.name }
이 코드 보다는 | 이 코드가 좋습니다. |
---|---|
.filter { it != null } .map { it !! } |
.filterNotNull() |
.map { <> } .filterNotnull() |
.mapNotNull{ <> } |
.map { <> } .joinToString() |
.joinToString{ <> } |
.filter { Predicate 1 } .filter { Predicate 2 } |
.filter { Predicate 1 && Predicate 2 } |
.filter { it is Type} .map{ it as Type } |
.filterIsInstance() |
.sortedBy{ <Key 2> } .sortedBy{ <Key 1> } |
.sortedWith(CompareBy( { <Key 1> }, { <Key 2> } ) |
.listOf(...) .filterNotNull() |
.listOfNotNull(...) |
.withIndex() .filter { (index, elem) -> } .map { it.value } |
.filterIndexed { index, elem -> } |
코틀린 기본 자료형은 primitive을 선언할 수 없지만, 최적화를 위해서 내부적으로는 사용할 수 있습니다. 기본 자료형은 당므과 같은 특징이 있습니다.
- 가볍습니다. 일반적인 객체와 다르게 추가적으로 포함되는 것들이 없기 때문입니다.
- 빠릅니다. 값에 접근할 때 추가 비용이 들어가지 않습니다.
코틀린에서 사용되는 List, Set 등의 컬렉션은 제네릭 타입니다. 제네릭 타입에는 기본 자료형을 사요할 수 없으므로, 랩핑된 타입을 사용해야 합니다. 성능이 중요한 코드라면 IntArray, LongArray 등의 기본 자료형을 활용하는 배열을 사용하는 것이 좋습니다.
단순하게 할당되는 영역만 생각해도 1,000,000개의 정수를 갖는 컬렉션을 만든다고 했을 때 IntArray는 400,000,016바이트, List는 2,000,006,944바이트를 할당합니다. 5배정도 차이가 발생합니다. 따라서 단순 메모리만 보더라도 기본 자료형을 사용하는 것이 좋습니다.
class `성능 테스트` {
lateinit var list: List<Int>
lateinit var array: IntArray
@BeforeEach
fun setUp() {
list = List(1_000_000) { it }
array = IntArray(1_000_000) { it }
}
@Test
fun `list`() {
// 평균 적으로 1,260,593 ns
list.average()
}
@Test
fun `array`() {
// 평균 적으로 868 509 ns
array.average()
}
}