숫자 리터럴

숫자 리터럴 (Numeric Literals)

⚠️ 원본 페이지(changed-features/numeric-literals.html)는 "Document was moved"로 표시되어 있어요. 내용이 실험(experimental) 섹션의 numeric-literals.html로 이동했길래, 아래 본문은 그 이동된 페이지의 실제 내용을 기준으로 번역했어요.

이 문서의 기능은 아직 Scala 3 언어 정의의 일부가 아니에요. 다음 언어 임포트로 사용할 수 있게 만들 수 있어요.

출처: Scala 3 Reference

본문

import scala.language.experimental.genericNumberLiterals

Scala 2에서 숫자 리터럴은 원시 숫자 타입인 Int, Long, Float, Double에만 국한되어 있었어요. Scala 3에서는 사용자 정의 타입에도 숫자 리터럴을 쓸 수 있죠. 예를 들어볼게요.

val x: Long = -10_000_000_000
val y: BigInt = 0x123_abc_789_def_345_678_901
val z: BigDecimal = 110_222_799_799.99

(y: BigInt) match
  case 123_456_789_012_345_678_901 =>

숫자 리터럴의 문법은 이전과 같지만, 크기에 대한 미리 정해진 한계는 없어요.

숫자 리터럴의 의미 (Meaning of Numeric Literals)

숫자 리터럴의 의미는 다음과 같이 결정돼요.

  • 리터럴이 l 또는 L로 끝나면 Long 정수예요(그리고 법적 범위에 맞아야 해요).
  • 리터럴이 f 또는 F로 끝나면 Float 타입의 단정밀도 부동소수점 수예요.
  • 리터럴이 d 또는 D로 끝나면 Double 타입의 배정밀도 부동소수점 수예요.

이 각각의 경우 숫자로의 변환은 Scala 2나 Java에서와 정확히 같아요. 숫자 리터럴이 이런 접미사 중 하나로 끝나지 않는다면, 그 의미는 기대 타입(expected type)에 따라 결정돼요.

  • 기대 타입이 Int, Long, Float, Double 중 하나라면, 리터럴은 그 타입의 표준 리터럴로 취급돼요.
  • 기대 타입이 scala.util.FromDigits[T] 타입의 given 인스턴스를 가진 완전히 정의된 타입 T라면, 리터럴은 그 인스턴스의 fromDigits 메서드의 인자로 넘겨져서 타입 T의 값으로 변환돼요(자세한 내용은 아래에서 다뤄요).
  • 그 외의 경우 리터럴은 (소수점이나 지수가 있으면) Double 리터럴로, 그렇지 않으면 Int 리터럴로 취급돼요. (마지막 가능성은 이번에도 Scala 2나 Java와 같아요.)

이 규칙들로 보면 다음 정의는,

val x: Long = -10_000_000_000

기대 타입이 Long이므로 규칙 (1)에 의해 유효해요. 정의들

val y: BigInt = 0x123_abc_789_def_345_678_901
val z: BigDecimal = 111222333444.55

BigIntBigDecimal 모두 FromDigits 인스턴스를 가지므로(각각 FromDigits.WithRadixFromDigits.Decimal 하위 클래스를 구현하죠) 규칙 (2)에 따라 유효해요. 반면에

val x = -10_000_000_000

는 타입 오류를 내요. 기대 타입이 없으면 -10_000_000_000은 규칙 (3)에 따라 Int 리터럴로 취급되는데, 그 타입에는 너무 크기 때문이에요.

FromDigits 트레이트

숫자 리터럴을 허용하려면, 타입이 scala.util.FromDigits 타입 클래스 또는 그 하위 클래스 중 하나의 given 인스턴스를 정의하기만 하면 돼요. FromDigits는 다음과 같이 정의돼요.

trait FromDigits[T]:
  def fromDigits(digits: String): T

fromDigits의 구현은 숫자의 문자열을 구현 타입 T의 값으로 변환해요. 숫자 문자열은 0과 9 사이의 숫자들로 이루어지고, 앞에 부호("+""-")가 붙을 수 있어요. 숫자 구분자 문자 _는 문자열이 fromDigits에 넘겨지기 전에 걸러져요.

FromDigits의 컴패니언 객체는 FromDigits의 하위 클래스들도 정의해요. 주어진 진법(radix)을 가진 정수용, 소수점이 있는 숫자용, 소수점과 지수 둘 다 있을 수 있는 숫자용이죠.

object FromDigits:

  /** A subclass of `FromDigits` that also allows to convert whole
   *  number literals with a radix other than 10
   */
  trait WithRadix[T] extends FromDigits[T]:
    def fromDigits(digits: String): T = fromDigits(digits, 10)
    def fromDigits(digits: String, radix: Int): T

  /** A subclass of `FromDigits` that also allows to convert number
   *  literals containing a decimal point ".".
   */
  trait Decimal[T] extends FromDigits[T]

  /** A subclass of `FromDigits`that allows also to convert number
   *  literals containing a decimal point "." or an
   *  exponent `('e' | 'E')['+' | '-']digit digit*`.
   */
  trait Floating[T] extends Decimal[T]

사용자 정의 숫자 타입은 이 중 하나를 구현할 수 있는데, 이는 컴파일러에게 16진수, 소수점, 지수가 이 타입의 리터럴에서도 허용된다는 신호가 돼요.

오류 처리 (Error Handling)

FromDigits 구현은 FromDigitsException의 어떤 하위 타입 예외를 던져서 오류를 신호할 수 있어요. FromDigitsExceptionFromDigits 객체 안에 세 개의 하위 클래스를 두고 다음과 같이 정의돼요.

abstract class FromDigitsException(msg: String) extends NumberFormatException(msg)

class NumberTooLarge (msg: String = "number too large")         extends FromDigitsException(msg)
class NumberTooSmall (msg: String = "number too small")         extends FromDigitsException(msg)
class MalformedNumber(msg: String = "malformed number literal") extends FromDigitsException(msg)

예시 (Example)

완전히 동작하는 예시로, 숫자 리터럴을 받아들이는 새 숫자 클래스 BigFloat의 구현을 보여드릴게요. BigFloatBigInt 가수(mantissa)와 Int 지수(exponent)로 정의돼요.

case class BigFloat(mantissa: BigInt, exponent: Int):
  override def toString = s"${mantissa}e${exponent}"

BigFloat 리터럴은 소수점과 지수 둘 다 가질 수 있어요. 예를 들어 다음 식은 BigFloat 숫자 BigFloat(-123, 997)을 만들어야 해요.

-0.123E+1000: BigFloat

BigFloat의 컴패니언 객체는 숫자 문자열에서 BigFloat를 만드는 apply 생성자 메서드를 정의해요. 가능한 구현은 다음과 같아요.

object BigFloat:
  import scala.util.FromDigits

  def apply(digits: String): BigFloat =
    val (mantissaDigits, givenExponent) =
      digits.toUpperCase.split('E') match
        case Array(mantissaDigits, edigits) =>
          val expo =
            try FromDigits.intFromDigits(edigits)
            catch case ex: FromDigits.NumberTooLarge =>
              throw FromDigits.NumberTooLarge(s"exponent too large: $edigits")
          (mantissaDigits, expo)
        case Array(mantissaDigits) =>
          (mantissaDigits, 0)
    val (intPart, exponent) =
      mantissaDigits.split('.') match
        case Array(intPart, decimalPart) =>
          (intPart ++ decimalPart, givenExponent - decimalPart.length)
        case Array(intPart) =>
          (intPart, givenExponent)
    BigFloat(BigInt(intPart), exponent)

BigFloat 리터럴을 받아들이려면, 추가로 필요한 것은 FromDigits.Floating[BigFloat] 타입의 given 인스턴스뿐이에요.

  given FromDigits: FromDigits.Floating[BigFloat]:
    def fromDigits(digits: String) = apply(digits)
end BigFloat

apply 메서드는 digits 인자의 형식을 검사하지 않는다는 점에 주의하세요. 유효한 인자만 넘겨진다고 가정해요. 컴파일러에서 오는 호출에 대해서는 그 가정이 타당해요. 컴파일러가 변환 메서드에 넘기기 전에 숫자 리터럴의 형식이 올바른지 먼저 검사하거든요.

컴파일 타임 오류 (Compile-Time Errors)

이전 절의 구성으로, 다음과 같은 리터럴은

1e10_0000_000_000: BigFloat

컴파일러에 의해 다음과 같이 확장돼요.

BigFloat.FromDigits.fromDigits("1e100000000000")

이 식을 평가하면 런타임에 NumberTooLarge 예외가 던져져요. 우리는 대신 컴파일 타임 오류를 만들고 싶을 거예요. BigFloat 클래스에 약간의 메타프로그래밍을 섞으면 이걸 이룰 수 있어요. 아이디어는 fromDigits 메서드를 매크로로, 즉 오른쪽에 스플라이스를 둔 인라인 메서드(inline method)로 만드는 거예요. 그러려면 BigFloat 객체 안의 FromDigits 인스턴스를 다음 두 정의로 바꿔 주세요.

object BigFloat:
  ...

  class FromDigits extends FromDigits.Floating[BigFloat]:
    def fromDigits(digits: String) = apply(digits)

  given FromDigits:
    override inline def fromDigits(digits: String) = ${
      fromDigitsImpl('digits)
    }

인라인 메서드는 런타임에 실행할 수 있는 코드를 만들지 않기 때문에 추상 메서드를 직접 대신할 수 없다는 점에 주의하세요. 그래서 중간 클래스 FromDigits를 두고 그 안에 폴백(fallback) 구현을 넣은 뒤, FromDigits given 인스턴스의 인라인 메서드가 그것을 오버라이드하도록 한 거예요. 그 메서드는 매크로 구현 메서드인 fromDigitsImpl로 정의돼요. 정의는 다음과 같아요.

  private def fromDigitsImpl(digits: Expr[String])(using ctx: Quotes): Expr[BigFloat] =
    digits.value match
      case Some(ds) =>
        try
          val BigFloat(m, e) = apply(ds)
          '{BigFloat(${Expr(m)}, ${Expr(e)})}
        catch case ex: FromDigits.FromDigitsException =>
          ctx.error(ex.getMessage)
          '{BigFloat(0, 0)}
      case None =>
        '{apply($digits)}
end BigFloat

매크로 구현은 Expr[String] 타입의 인자를 받아 Expr[BigFloat] 타입의 결과를 내요. 인자가 상수 문자열인지 검사한 뒤, 상수라면 apply 메서드로 그 문자열을 변환하고 결과 BigFloat를 다시 Expr 수준으로 끌어올려요. 상수가 아닌 문자열에 대해 fromDigitsImpl(digits)는 그냥 apply(digits)라서, 이 경우에는 모든 게 런타임에 평가돼요.

흥미로운 부분은 digits가 상수인 경우의 catch 부분이에요. apply 메서드가 FromDigitsException을 던지면, 그 예외의 메시지가 ctx.error(ex.getMessage) 호출에서 컴파일 타임 오류로 발행돼요.

이 새 구현으로, 다음 정의는

val x: BigFloat = 1234.45e3333333333

이런 컴파일 타임 오류 메시지를 내요.

3 |  val x: BigFloat = 1234.45e3333333333
  |                    ^^^^^^^^^^^^^^^^^^
  |                    exponent too large: 3333333333

더 알아보기 (Learn more)