숫자 리터럴

숫자 리터럴 (Numeric Literals)

사용자가 정의한 타입에도 숫자 리터럴을 쓸 수 있게 해주는 실험적 기능이에요. Long 같은 표준 숫자 타입뿐 아니라 BigInt 같은 타입에 리터럴을 직접 쓸 수 있게 되죠.

출처: Scala 3 Reference

본문

이 기능은 아직 Scala 3 언어 정의의 일부가 아니에요. 언어 import로 사용할 수 있게 할 수 있어요:

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

는 규칙 (1)에 의해 합법적이에요. 기대 타입이 Long이니까요. 정의

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

는 규칙 (2)에 의해 합법적이에요. BigIntBigDecimal 모두 FromDigits 인스턴스를 가지니까요(각각 FromDigits.WithRadixFromDigits.Decimal 하위 클래스를 구현해요). 반면에

val x = -10_000_000_000

는 타입 오류를 줘요. 기대 타입이 없으면 규칙 (3)에 따라 -10_000_000_000Int 리터럴로 취급되는데, 그 타입에는 너무 크니까요.

FromDigits 트레이트 (The FromDigits Trait)

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

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

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

동반 객체 FromDigits는 주어진 기수(radix)를 가진 정수, 소수점을 가진 수, 소수점과 지수 둘 다 가질 수 있는 수에 대한 FromDigits의 하위 클래스도 정의해요:

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 가수와 Int 지수로 정의됩니다:

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

BigFloat 리터럴은 소수점과 지수 둘 다 가질 수 있어요. 예를 들어 다음 표현식은 BigFloat(-123, 997)이라는 BigFloat 수를 만들어야 해요:

-0.123E+1000: BigFloat

BigFloat의 동반 객체는 digits 문자열에서 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 메서드로 바꾸는 거예요. 이렇게 하려면 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)
    }

inline 메서드는 추상 메서드를 직접 대신할 수 없다는 점에 주목하세요. 실행 가능한 코드를 만들지 않으니까요. 그래서 대체 구현을 담은 중간 클래스 FromDigits를 정의하고, FromDigits given 인스턴스의 inline 메서드로 그걸 오버라이드하는 거예요. 그 메서드는 매크로 구현 메서드 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