숫자 리터럴
숫자 리터럴 (Numeric Literals)
사용자가 정의한 타입에도 숫자 리터럴을 쓸 수 있게 해주는 실험적 기능이에요. Long 같은 표준 숫자 타입뿐 아니라 BigInt 같은 타입에 리터럴을 직접 쓸 수 있게 되죠.
본문
이 기능은 아직 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)에 의해 합법적이에요. BigInt와 BigDecimal 모두 FromDigits 인스턴스를 가지니까요(각각 FromDigits.WithRadix와 FromDigits.Decimal 하위 클래스를 구현해요). 반면에
val x = -10_000_000_000
는 타입 오류를 줘요. 기대 타입이 없으면 규칙 (3)에 따라 -10_000_000_000이 Int 리터럴로 취급되는데, 그 타입에는 너무 크니까요.
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의 어떤 하위 타입의 예외를 던져서 오류를 알릴 수 있어요. FromDigitsException은 FromDigits 객체에서 세 개의 하위 클래스로 다음과 같이 정의됩니다:
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의 구현이 있어요. BigFloat는 BigInt 가수와 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