숫자 리터럴
숫자 리터럴 (Numeric Literals)
⚠️ 원본 페이지(changed-features/numeric-literals.html)는 "Document was moved"로 표시되어 있어요. 내용이 실험(experimental) 섹션의 numeric-literals.html로 이동했길래, 아래 본문은 그 이동된 페이지의 실제 내용을 기준으로 번역했어요.
이 문서의 기능은 아직 Scala 3 언어 정의의 일부가 아니에요. 다음 언어 임포트로 사용할 수 있게 만들 수 있어요.
본문
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
은 BigInt와 BigDecimal 모두 FromDigits 인스턴스를 가지므로(각각 FromDigits.WithRadix와 FromDigits.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의 어떤 하위 타입 예외를 던져서 오류를 신호할 수 있어요. 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 가수(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