본문 바로가기

Kotlin

공식 문서로 배우는 Kotlin - 4. Basic Types - Numbers

네번째는 Concepts 부분으로 넘어와서 기본 타입 - 숫자입니다.

 

Kotlin에서는 어떤 변수든 멤버 함수와 프로퍼티를 호출할 수 있다는 면에서 모든 것이 객체입니다. 특정 타입들은 실행시에 내부적으로 원시 타입(숫자, 문자, 불리언 등)으로 최적화 되기는 하지만, 외형이난 사용면에서는 일반적인 클래스와 같습니다.

 

자연스러운 개념일 수도 있으나 언어 차원에서 원시 타입이 없이 모든 것을 객체로 본다는 개념은 기억하는 것이 좋습니다. 이 개념을 기본적으로 염두에 두고 있으면 추후 동반 객체 등을 이해할 때 도움이 됩니다.  꼭, 기억해 두시기 바랍니다.

 

타입(type) vs 형(形)
타입과 형(形) 모두 같은 말이지만, OOP에서 객체의 유형을 얘기할 때 보통 형보다는 타입이라는 말을 많이 쓰고, Kotlin에서 모든 것이 객체라는 의미가 잘 나타나게, 좀 어색할 수 있지만 '정수 타입' 같이 '타입'이라는 단어를 (적어도) 제목에는 사용하도록 하겠습니다. 문장에서는 자연스러움을 유지하기 위해 섞어서 쓰도록 하겠습니다. 

 

숫자

정수 타입

Kotlin은 숫자를 표현하는 내장된 타입을 제공합니다.

정수와 관련해서는 서로 크기와 (그에 따른) 범위가 다른 네가지 타입이 있습니다.

타입 크기 (bits) 최솟값 최댓값
Byte 8 -128 127
Short 16 -32768 32767
Int 32 -2,147,483,648 (-231) 2,147,483,647 (231 - 1)
Long 64 -9,223,372,036,854,775,808 (-263) 9,223,372,036,854,775,807 (263 - 1)

 

명시적인 타입 지정 없이 변수를 초기화하면, 컴파일러는  Int 부터 시작해서 해당 값을 표현할 수 있는 가장 작은 범위의 타입으로 추론합니다. 즉, 값이 Int 범위를 넘어서지 않으면 Int로 그렇지 않으면, Long으로 지정합니다. 값을 Long 값으로 명시적으로 지정하기 위해서는 숫자 끝에 L을 붙입니다. 명시적으로 타입을 지정하면 컴파일러는 값이 해당 타입의 범위를 넘어서지 않는지 검사합니다.

val one = 1 // Int
val threeBillion = 3000000000 // Long
val oneLong = 1L // Long
val oneByte: Byte = 1

 

부동 소수점 타입

실수를 위해 Kotlin은 IEEE 754 표준을 준수하는 부동 소수점 타입인 Float와 Double을 제공합니다. Float는 IEEE 754의 단정밀도를 따르고, Double은 IEEE 754의 배정밀도를 따릅니다.

 

각각의 타입에 대한 세부 정보는 다음과 같습니다.

타입 크기(bits) 유효 비트 지수 비트 가수 비트
Float 32 24 8 6-7
Double 64 53 11 15-16

 

Float와 Double형 변수는 소수 부분이 있는 숫자(실수)로 초기화 할 수 있습니다. 실수로 초기화 되는 변수는 컴파일러가 Long으로 간주합니다.

val pi = 3.14 // Double
// val one: Double = 1 // Error: type mismatch
val oneDouble = 1.0 // Double

 

명시적으로 Float 타입으로 지정하기 위해서는 값 뒤에  fF를 붙입니다. 명시적으로 Float로 지정할 때, 값의 가수 부분이 6 ~ 7개를 넘어서면 반내림 됩니다.

val e = 2.7182818284 // Double
val eFloat = 2.7182818284f // Float, actual value is 2.7182817

 

다른 언어들과 달리, Kotlin에는 숫자에 대해서 묵시적인 확대 타입 변환이 없습니다. 예를 들어, Double 형 매개변수가 있는 함수는 오로지 Double 형 인수를 넘길 때에만 호출할 수 있습니다. Float, Int, 그 외 다른 숫자값으로는 호출할 수 없습니다.

fun main() {
    fun printDouble(d: Double) { print(d) }

    val i = 1
    val d = 1.0
    val f = 1.0f

    printDouble(d)
//    printDouble(i) // Error: Type mismatch
//    printDouble(f) // Error: Type mismatch
}

 

숫자 값을 다른 타입으로 변경할 때는 명시적인 변환을 사용합니다.

 

숫자 값을 위한 리터럴 상수

정수 리터럴

  • 정수: 123
  • 대문자 L로 Long 타입 지정: 123L
  • 16진수: 0x0F
  • 이진수:  0b00001011
Kotlin에서 8진수는 지원하지 않습니다.

 

부동 소수점 수에 대해서는 다음과 같이 일반적인 표기법을 지원합니다.

  • 기본적으로 Double형: 123.5, 123.5e10
  • f나 F를 붙인 Float형: 123.5f
val oneMillion = 1_000_000
val creditCardNumber = 1234_5678_9012_3456L
val socialSecurityNumber = 999_99_9999L
val hexBytes = 0xFF_EC_DE_5E
val bytes = 0b11010010_01101001_10010100_10010010

 

부호 없는 정수 타입을 나타내는 접미어(u, ul)도 있습니다. 상세한 내용은 부호없는 정수 타입을 참고하세요. 

리터럴(literal)이란?
리터럴이란 프로그래밍 소스 코드에 기록된 값의 (변하지 않는) 텍스트 표현입니다.
val foo = 123.5
라는 코드에서 123.5는 숫자값을 나타내는 텍스트이며, 변하지 않은 소스 코드의 일부분입니다. 이를 리터럴 또는 리터럴 상수라고 합니다. 변하지 않는다는 의미로 상수(constant)라는 표현을 쓰기 때문에 코딩할 때 상수 정의와 헷갈려 하는 경우가 있는 데, 관련이 없습니다. "값의 표현"에서 값은 일반적인 숫자, 문자에서부터 (언어에 따라) 객체 등도 될 수 있습니다.

 

JVM에서의 수의 표현

JVM 플랫폼에서는 수는 int, double 등과 같은 원시 타입에 저장됩니다. 예외적인 경우는 Int? 같이 널 가능한 숫자 참조를 만들거나 제네릭을 사용할 때입니다. 이런 경우에 숫자는 Java의 Integer, Double 같은 원시 타입의 랩퍼 클래스가 사용됩니다.

 

같은 수에 대한 널 가능한 (참조)변수는 서로 다른 객체를 참조하고 있을 수 있습니다.

fun main() {
    val a: Int = 100
    val boxedA: Int? = a
    val anotherBoxedA: Int? = a

    val b: Int = 10000
    val boxedB: Int? = b
    val anotherBoxedB: Int? = b

    println(boxedA === anotherBoxedA) // true
    println(boxedB === anotherBoxedB) // false
}

 

a에 대한 모든 널 가능한 변수들은 실제로 같은 객체를 참조하고 있습니다. 그 이유는, JVM이 최적화를 위해 -128 ~ 127의 값에 대해서는 (랩퍼 클래스인) Interger를 적용하기 때문입니다. 하지만, b는 원시 타입이고, 그래서 boxedB와 anotherBoxedB에는 각각 10000에 대한 새로운 Interger 객체가 할당되기 다른 객체입니다.

 

참조에 대해서는 그렇지만, 값에 대해서는 동일합니다.

fun main() {
    val b: Int = 10000
    println(b == b) // Prints 'true'
    val boxedB: Int? = b
    val anotherBoxedB: Int? = b
    println(boxedB == anotherBoxedB) // Prints 'true'
}

 

명시적 변환

작은 범위의 숫자 타입은 큰 것의 서브 타입이 아닙니다. 만약 그렇다면 다음과 같은 경우에 문제가 발생합니다.

// 가상의 코드. 실제로 컴파일 되지 않습니다.
val a: Int? = 1 // java.lang.Integer
val b: Long? = a // 묵시적인 변환으로 java.lang.Long이 됩니다.
print(b == a) // Long의 equals()는 대상 또한 Long인지 확인하기 때문에, (놀랍게도) false를 출력합니다.

 

결국, 정체성은 물론 동등성(equality)까지 소리 없이 사라지게 됩니다.

이러한 이유 때문에 작은 타입은 큰 타입으로 묵시적 변환되지 않습니다. 그래서, Byte 값을 Int 변수에 할당할 때는 명시적인 변환이 필요합니다.

fun main() {
    val b: Byte = 1 // 정상. 리터널은 정적으로 검사됩니다.
    // val i: Int = b // 오류
    val i1: Int = b.toInt()
}

 

모든 숫자 타입은 다른 타입으로 변환할 수 있는 다음과 같은 메소드를 제공합니다.

  • toByte()
  • toShort()
  • toInt()
  • toLong()
  • toFloat()
  • toDouble()

코드의 문맥으로부터 타입이 추론되고, 산술 연산 같은 경우는 적절한 변환이 적용되기 때문에, 대부분의 경우 명시적인 변환은 필요하지 않습니다.

val l = 1L + 3 // Long + Int => Long

 

연산

Kotlin은 +, -, *, /, %(나머지 연산) 같은 기본적인 산술 연산을 지원합니다. 각 연산자들은 숫자형 클래스들에 멤버로 선언돼 있습니다.

fun main() {
    println(1 + 2)
    println(2_500_000_000L - 1L)
    println(3.14 * 2.71)
    println(10.0 / 3)
}

 

연산자가 클래스 멤버로 정의돼 있는 예는 여기를 참고하시면 됩니다.

 

클래스 멤버이기 맞춤화 클래스를 위해 이러한 연산자를 오버라이딩할 수 있습니다. 방법은 추후에 함수부분에서 살펴보게 됩니다. 지금 확인하고 싶은 경우에는 연산자 오버로딩을 보시면 됩니다.

 

정수의 나눗셈

정수끼리의 나눗셈은 항상 정수를 반환합니다. 실수부는 버려집니다.

fun main() {
    val x = 5 / 2
    //println(x == 2.5) // 오류: '==' 연산자는 'Int'와 'Double' 사이에 적용할 수 없습니다.
    println(x == 2) // true
}

 

다음과 같이 어떤 정수 타입이든 상관 없이 섞어서 나눗셈이 가능합니다.

fun main() {
    val x = 5L / 2
    println(x == 2L) // true
}

 

부동소수점 타입을 반환하기 위해서는 피연산자 중 하나를 명시적으로 변환해야 합니다.

fun main() {
    val x = 5 / 2.toDouble()
    println(x == 2.5) // true
}

 

비트 연산

Kotlin은 정수에 대해서 비트 연산을 제공합니다. 비트 연산자는 숫자 표현의 비트에 대해서 직접적으로 비트 연산을 수행합니다. 비트 연산자는 중위(infix) 형식으로 사용할 수 있는 함수로 구현돼 있습니다. 비트 연산은 Int와 Long에 대해서만 실행할 수 있습니다.

val x = (1 shl 2) and 0x000FF000

 

다음은 모든 비트 연산 목록입니다.

  • shl : signed shift left
  • shr: signed shift right
  • ushr: unsigned shift right
  • and
  • or
  • xor
  • inv: inversion. not 연산

 

부동 소수점 숫자 비교

여기서 살펴볼 부동 소수점 숫자에 연산은 다음과 같습니다.

  • 동일성 검사: a == b 와 a != b
  • 비교 연산자: a < b, a > b, a <= b, a >= b
  • 범위 사용 및 검사: a..b, x in a..b, x !in a..b

a와 b가 정적으로 Float나 Double이거나 그에 해당하는 (선언 또는 추론 또는 스마트 캐스트에 의한) 널 가능 타입일 때, 숫자에 대한 연산이나 범위 연산은 부동 소수점 연산을 위한  IEEE 754 표준을 따릅니다.

 

하지만, 제네릭을 사용하는 경우나 전체 순서 지정을 제공하기 위해서 정적으로 지정되지 않은 부동 소수점 피연산자에 대해서는 다르게 동작합니다. 예를 들면, Any, Comparable<...>, Collection<T> 같은 경우입니다. 이런 경우에 비교 연산은 Float와 Double을 위한 equals와 compareTo가 사용됩니다. 그 결과로 다음과 같이 동작합니다.

  • NaN은 그 자체와 같다고 간주됩니다.
  • NaN은 POSITIVE_INFINITY(양의 무한대 값 상수) 포함하여 어떤 수 보다도 큰 것으로 간주합니다.
  • -0.0은 0.0 보다 작은 것으로 간주합니다.

다음은 정적 타입 부동 소수점 연산과 정적 타입이 아닌 부동 소수점 연산의 차이점을 보여주는 예입니다.

fun main() {
    // 정적 타입 부동 소수점 피연산자
    println(Double.NaN == Double.NaN)                 // false
    // 정적 타입이 아닌 부동 소수점 피연산자이기 때문에,
    // NaN은 NaN과 같습니다.
    println(listOf(Double.NaN) == listOf(Double.NaN)) // true

    // 정적 타입 부동 소수점 피연산자
    println(0.0 == -0.0)                              // true
    // 정적 타입이 아닌 부동 소수점 피연산자이기 때문에,
    // -0.0은 0.0 보다 작습니다.
    println(listOf(0.0) == listOf(-0.0))              // false

    println(listOf(Double.NaN, Double.POSITIVE_INFINITY, 0.0, -0.0).sorted())
    // [-0.0, 0.0, Infinity, NaN]
}