
코틀린의 기본 타입과 컬렉션, 배열에 대한 개념 정리로 돌아온 개발자 꿈나무 김조현입니다.
이번 글에서는 코틀린에서 사용되는 Int, Double 등의 기본 타입과 컬렉션, 배열에 대해 자세하게 알아보며 자바와 어떤 점이 다른지에 대해 정리해보겠습니다.
⭐️ 자바와 코틀린의 차이점
자바는 원시 타입과 참조 타입을 구분합니다. 원시 타입의 값을 더 효율적으로 저장하고 전달할 수 있지만, 메소드를 호출하거나 컬렉션 등에서는 원시 타입 값을 담을 수 없습니다. 그렇기에 자바는 원시 타입을 특별한 래퍼 타입으로 감싸서 사용합니다.
반면 코틀린은 원시 타입과 래퍼 타입을 구분하지 않아 항상 같은 타입을 사용합니다. 이 방식이 훨씬 편리하다는 장점이 있습니다.
fun showProgress(progress: Int) {
val percent = progress.coerceIn(0, 100)
println("We're $percent % done!")
}
fun main() {
showProgress(146)
// We're 100 % done!
}
코틀린은 실행 시점에 가능한 한 가장 효율적인 방식으로 표현합니다. 예를 들어 코틀린의 Int 타입은 자바 int 타입으로 컴파일됩니다. 만약 컬렉션의 타입 파라미터로 넘기면 그 컬렉션에는 Int의 래퍼 타입에 해당하는 java.lang.Integer 객체로 사용합니다.
예를 들어 Int는 약 음수 20억에서 양수 20억의 범위를 가집니다. 하지만 양수를 표현하기 위해 모든 범위를 사용하고 싶을 수도 있을 것입니다. 이럴 때 사용하는 것이 부호 없는 타입입니다.
부호 없는 숫자 타입들은 상응하는 부호 있는 타입의 범위를 시프트해서 같은 크기의 메모리에 사용해 더 큰 양수 범위를 표현할 수 있게 해줍니다. 위에서 예를 든 Int의 경우는 UInt로 부호 없는 숫자 타입을 사용할 수 있으며 0에서 약 40억의 범위를 가집니다.
앞에서도 설명했듯이 널이 될 수 있는 코틀린 타입은 자바 원시 타입으로 표현할 수 없습니다. 따라서 코틀린에서 널이 될 수 있는 원시 타입을 사용하면 그 타입은 자바의 래퍼 타입으로 컴파일됩니다.
data class Person(val name: String, val age: Int? = null) {
fun isOlderThan(other: Person): Boolean? {
if (age == null || other.age == null)
return null
return age > other.age
}
}
fun main() {
println(Person("Sam", 35).isOlderThan(Person("Amy", 42)))
// false
println(Person("Sam", 35).isOlderThan(Person("Jane")))
// null
}
age는 널 가능성이 있으므로 Int? 타입의 두 값을 직접 비교할 수 없기 때문에 먼저 null 검사를 한 후에 두 값을 비교해야 합니다.
Person클래스에 선언된 age 프로퍼티의 값은 위에서 설명한 것처럼 java.lang.Integer로 저장됩니다.
⭐️ 자바와 코틀린의 차이점
코틀린은 한 타입의 수를 다른 타입의 수로 자동 변환하지 않습니다. 결과 타입이 허용하는 수의 범위가 원래 타입의 범위보다 넓은 경우에도 자동 변환은 불가능합니다.
val i = 1
val l: Long = i
// 컴파일되지 않는다.
자동 변환이 안되는 대신 코틀린에서는 직접 변환 메소드를 호출해야 합니다.
val i = 1
val l: Long = i.toLong()
코틀린은 모든 원시 타입에 대해 toByte(), toShort(), toChar() 등의 변환 함수를 제공합니다. 명시하는 방식으로 언어를 설계한 이유는 개발자의 혼란을 피하고자는 이유를 갖고 설계한 것입니다.
⭐️ 원시 타입 리터럴
수 리터럴은 다음과 같다.
- L 접미사가 붙은 Long타입 리터럴: 123L
- 표준 부동소수점 표기법을 사용한 Double타입 리터럴: 0.12, 2.0, 1.2e10
- f나 F 접미사가 붙은 Float 타입 리터럴: 123.4f, .456F
- 0x나 0X 접두사가 붙은 16진 리터럴: 0xCAFEBAEBC
- 0b나 0B 접두사가 붙은 2진 리터럴: 0b000000101
- U 접미사가 붙은 부호 없는 정수 리터럴: 123U, 123UL, 0x10cU
문자 리터럴은 다음과 같다.
- 작은 따옴표 안에 문자 넣기: ‘1’
- 이스케이프 시퀀스 사용하기: ‘\t’, ‘u009’
숫자 리터럴을 사용할 떄는 toLong(), toDouble() 등과 같은 보통 변환 함수를 호출할 필요가 없습니다. 또한 직접 변환하지 않더라도 숫자 리터럴 타입이 알려진 변수에 대입하거나 함수에게 인자로 넘기면 컴파일러가 필요한 변환을 자동으로 넣어줍니다.
fun printALong(l: Long) = println(l)
fun main() {
val b: Byte = 1
// 1을 바이트로 해석한다.
val l = b + 1L
printALong(42)
// 42
}
자바와 똑같이 숫자 연산 시 오버플로우나 언더플로우가 발생할 수 있습니다.
fun main() {
println(Int.MAX_VALUE + 1)
// -2147483648
println(Int.MIN_VALUE - 1)
// 2147483647
}
toInt(), toByte() 등을 사용하면 문자열의 내용을 각 타입으로 파싱하려고 시도합니다. 파싱에 실패한다면 NumberFormatException이 발생합니다.
fun main() {
println("42".toInt())
// 42
}
하지만 문자열을 수로 변환할 때 실패하리라 예상이 되는 경우에는 toIntOrNull, toByteOrNull 등 각 확장 함수에서 실패하면 null을 반환해주는 함수를 사용하면 됩니다.
fun main() {
println("seven".toIntOrNull())
// null
}
toBoolean 함수는 인자로 받은 문자열이 null이 아니고 내용이 단어 true와 대소문자 상관없이 같으면 true를 반환하고, 그 외에는 false를 반환합니다. 반면 대소문자를 구분하여 정확히 true나 false와 일치시키고 싶다면 toBooleanStrict 함수를 사용하면 됩니다.
fun main() {
println("TrUE".toBoolean())
// true
println("7".toBoolean())
// false
println(null.toBoolean())
// false
}
코틀린에서 Any 타입은 모든 널이 될 수 없는 타입의 조상 타입으로, 자바에서 Object와 비슷한 개념입니다. 하지만 자바의 Object는 참조 타입만 정점으로 하는 타입 계층에 포함되기 때문에 Object 타입이 필요한 경우에는 원시 타입을 특별한 래퍼에 감싸서 활용해야 합니다. 하지만 코틀린에서는 Any가 원시 타입을 포함한 모든 타입의 조상 타입입니다.
Any는 널이 될 수 없지만, null을 넣고 싶다면 Any? 타입을 사용하면 됩니다.
자바 메소드에서 Object를 인자로 받거나 반환하면 코틀린에서는 Any로 취급하며, 반대로 코틀린 함수가 Any를 사용하면 자바 바이트코드의 Object로 컴파일됩니다.
코틀린의 Unit 타입은 자바의 void와 같은 기능을 하며, 내용을 전혀 반환하지 않는 함수의 반환 타입으로 사용할 수 있습니다. 함수가 Unit을 반환한다면 함수 선언시 반환 타입을 생략할 수 있으며, 컴파일러가 암시적으로 return Unit을 넣어주기 때문에 명시적으로 return Unit을 할 필요가 없습니다.
⭐️ 코틀린의 Unit과 자바 void의 다른 점은?
Unit은 모든 기능을 갖는 일반적인 타입으로 void와 달리 타입 인자로 사용할 수 있다. 코틀린에서는 return Unit을 생략할 수 있는 반면 자바의 void는 return을 항상 명시적으로 사용해야 한다.
interface Processor<T> {
fun process(): T
}
class NoResultProcessor: Processor<Unit> {
override fun process() { }
}
Noting 타입은 테스트할 때 사용하는 함수와 같이 성공적으로 값을 돌려주지 않는 함수를 표현하고자할 때 유용하게 활용할 수 있습니다.
fun fail(message: String): Nothing {
throw IllegalStateException(message)
}
fun main() {
fail("Error occurred")
// Exception in thread "main" java.lang.IllegalStateException: Error occurred
}
Nothing 타입은 아무 값도 포함하지 않으며, 함수의 반환 타입이나 반환 타입으로 쓰일 타입 파라미터로만 쓰일 수 있습니다.
컬렉션에 대해서는 3장에서도 다뤘지만, 이번 장에서는 null을 사용하거나 값을 변경하는 등 컬렉션에 대해 깊게 파볼 것입니다.
컬렉션에 null을 넣을 수 있는지 여부는 신경써서 표시해야합니다. List<Int> 리스트에서 널 가능성을 표시할 경우 List<Int?> 와 List<Int>?에는 차이점이 있습니다. List<Int?>는 리스트 자체는 null이 될 수 없지만, 각 원소는 null이 될 수 있습니다. List<Int>?의 경우는 원소는 null이 될 수 없지만, 리스트는 null이 될 수 있습니다.
fun readNumbers(text: String): List<Int?> =
text.lineSequence().map{ it.toIntOrNull() }.toList()
fun addValidNumbers(numbers: List<Int?>) {
var sumOfValidNumbers = 0
var invalidNumbers = 0
for (number in numbers) {
if (number != null) {
sumOfValidNumbers += number
} else {
invalidNumbers++
}
}
println("Sum of valid numbers: $sumOfValidNumbers")
println("Invalid numbers: $invalidNumbers")
}
fun main() {
val input = """
1
abc
42
""".trimIndent()
val numbers = readNumbers(input)
addValidNumbers(numbers)
// Sum of valid numbers: 43
// Invalid numbers: 1
}
또한 널이 될 수 있는 값으로 이뤄진 컬렉션으로 null 값을 걸러내는 경우에는 코틀린 표준 라이브러리에 있는 filterNotNull이라는 코틀린이 제공하는 함수를 사용하면 됩니다.
fun addValidNumbers(numbers: List<Int?>) {
val validNumbers = numbers.filterNotNull()
println("Sum of valid numbers: ${validNumbers.sum()}")
println("Invalid numbers: ${numbers.size - validNumbers.size}")
}
코틀린 컬렉션은 자바 컬렉션과 다르게 컬렉션 안의 데이터에 접근하는 인터페이스와 컬렉션 안의 데이터를 변경하는 인터페이스를 분리했습니다.
코틀린의 Collection 인터페이스를 사용하면 컬렉션 안의 원소에 대해 이터레이션할 수 있고, 컬렉션의 크기를 얻을 수 있고, 어떤 값이 컬렉션에 들어있는지 검사하고, 데이터를 읽는 다른 연산을 수행할 수 있지만 원소를 추가하거나 제거하는 메소드가 없습니다.
컬렉션의 데이터를 수정하려면 MutableCollection을 사용해야 합니다. MutableCollection은 Collection을 확장하면서 원소를 추가, 삭제하거나 컬렉션 안의 원소를 모두 지우는 등의 메소드를 더 제공합니다.
fun <T> copyElements(source: Collection<T>,
target: MutableCollection<T>) {
for (item in source) {
target.add(item)
}
}
fun main() {
val source: Collection<Int> = arrayListOf(3, 5, 7)
val target: MutableCollection<Int> = arrayListOf(1)
copyElements(source, target)
println(target)
// [1, 3, 5, 7]
}
모든 코틀린 컬렉션은 자바 컬렉션 인터페이스의 인스턴스 입니다. 따라서 코틀린과 자바 사이를 오갈 때 아무 변환도 필요 없습니다.
하지만 위에서도 말한 것처럼 코틀린은 자바와 다르게 읽기 전용과 변경 가능한 인터페이스 총 2가지로 표현을 제공합니다.
이런 성질로 인해 컬렉션의 변경 가능성과 관련해 중요한 문제가 발생하는데, 자바는 코틀린처럼 인터페이스를 구분하지 않기 때문에 코틀린에서 읽기 전용으로 선언된 객체라도 자바에서는 그 컬렉션 객체의 내용을 변경할 수 있습니다.
또한 이런 문제는 널이 아닌 원소로 이뤄진 컬렉션 타입에서도 발생할 수 있습니다. 널이 아닌 원소로 이뤄진 컬렉션을 자바 메소드로 넘겼는데, 자바 메소드가 널을 컬렉션에 넣을 수도 있습니다.
이런 문제를 해결하기 위해서는 프로그래머가 컬렉션을 변경할지 여부에 따라 올바른 파라미터 타입을 사용해야할 책임을 가져야 합니다.
코틀린에서 배열을 만드는 방법은 다양합니다.
fun main() {
val letters = Array<String>(26) { i -> ('a' + i).toString() }
println(letters.joinToString(""))
// abcdefghijklmnopqrstuvwxyz
}
코틀린에서 배열은 배열을 인자로 받는 자바 함수를 호출하거나 vararg 파라미터를 받는 코틀린 함수를 호출하기 위할 때 가장 자주 배열을 만듭니다. 하지만 이미 데이터가 컬렉션에 들어가 있을 수도 있습니다. 이럴 때는 toTypedArray 메소드를 사용하면 컬렉션을 배열로 변경할 수 있습니다.
fun main() {
val strings = listOf("a", "b", "c")
println("%s%s%s".format(*strings.toTypedArray()))
// a/b/c
}
코틀린은 별도 클래스를 각 원시 타입마다 하나씩 제공합니다. 예를 들어 Int 타입으로 이뤄진 배열의 타입은 IntArray입니다. 이런 식으로 ByteArray, CharArray, BooleanArray 등이 있습니다.
이번 글에서는 코틀린의 기본 타입과 컬렉션, 배열에 대해 알아봤습니다.
이번 장에서는 확실히 자바와 관련된 개념이 많았기에 자바의 언어적 특성에 대해서도 더 알아볼 수 있었습니다.
코틀린에서는 자바에서 불편한 문제인 NullPointerException 문제를 해결하기 위해 기본 타입에서도 널을 허용하여 처리할 수 있도록 언어를 설계하였다는 것을 알게 되었습니다.
또한 코틀린의 컬렉션 라이브러리가 자바의 컬렉션 라이브러리와 완전히 일치하여, 컬렉션을 사용할 때 특별한 변경이 필요 없다는 사실을 알게 되었습니다. 하지만 자바와 코틀린의 컬렉션을 다루는 방법이 다르기 때문에 이는 개발자가 책임을 가지고 설계해야한다는 사실을 알게되었습니다. 이를 보고 타입을 자동으로 지정해준다던지 등 컴파일러가 똑똑하더라도 개발자가 부족한 부분을 채워줘야 한다는 것을 느꼈습니다.
다음에는 연산자 오버로딩과 다른 관례에 대해 정리하는 글로 돌아오겠습니다.
읽어주셔서 감사합니다!🙂↕️