Skip to content

Favour error over implicit conversions with generic number literals #7468

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions compiler/src/dotty/tools/dotc/core/Definitions.scala
Original file line number Diff line number Diff line change
Expand Up @@ -689,9 +689,11 @@ class Definitions {
@tu lazy val Stats_doRecord: Symbol = StatsModule.requiredMethod("doRecord")

@tu lazy val FromDigitsClass: ClassSymbol = ctx.requiredClass("scala.util.FromDigits")
@tu lazy val FromDigits_WithRadixClass: ClassSymbol = ctx.requiredClass("scala.util.FromDigits.WithRadix")
@tu lazy val FromDigits_DecimalClass: ClassSymbol = ctx.requiredClass("scala.util.FromDigits.Decimal")
@tu lazy val FromDigits_FloatingClass: ClassSymbol = ctx.requiredClass("scala.util.FromDigits.Floating")
@tu lazy val FromDigitsModule: TermSymbol = ctx.requiredModule("scala.util.FromDigits")
@tu lazy val FromDigits_fromDigits: Symbol = FromDigitsModule.requiredMethod(nme.fromDigits)
@tu lazy val FromDigits_fromRadixDigits: Symbol = FromDigitsModule.requiredMethod(nme.fromRadixDigits)
@tu lazy val FromDigits_fromDecimalDigits: Symbol = FromDigitsModule.requiredMethod(nme.fromDecimalDigits)
@tu lazy val FromDigits_fromFloatingDigits: Symbol = FromDigitsModule.requiredMethod(nme.fromFloatingDigits)

@tu lazy val XMLTopScopeModule: Symbol = ctx.requiredModule("scala.xml.TopScope")

Expand Down
3 changes: 3 additions & 0 deletions compiler/src/dotty/tools/dotc/core/StdNames.scala
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,9 @@ object StdNames {
val flatMap: N = "flatMap"
val foreach: N = "foreach"
val fromDigits: N = "fromDigits"
val fromRadixDigits: N = "fromRadixDigits"
val fromDecimalDigits: N = "fromDecimalDigits"
val fromFloatingDigits: N = "fromFloatingDigits"
val fromProduct: N = "fromProduct"
val genericArrayOps: N = "genericArrayOps"
val genericClass: N = "genericClass"
Expand Down
18 changes: 9 additions & 9 deletions compiler/src/dotty/tools/dotc/typer/Typer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -528,15 +528,15 @@ class Typer extends Namer
return lit(doubleFromDigits(digits))
else if (target.isValueType && isFullyDefined(target, ForceDegree.none)) {
// If expected type is defined with a FromDigits instance, use that one
val fromDigitsCls = tree.kind match {
case Whole(10) => defn.FromDigitsClass
case Whole(_) => defn.FromDigits_WithRadixClass
case Decimal => defn.FromDigits_DecimalClass
case Floating => defn.FromDigits_FloatingClass
}
inferImplicit(fromDigitsCls.typeRef.appliedTo(target), EmptyTree, tree.span) match {
case SearchSuccess(arg, _, _) =>
val fromDigits = untpd.Select(untpd.TypedSplice(arg), nme.fromDigits).withSpan(tree.span)
val summoner = tree.kind match
case Whole(10) => defn.FromDigits_fromDigits
case Whole(_) => defn.FromDigits_fromRadixDigits
case Decimal => defn.FromDigits_fromDecimalDigits
case Floating => defn.FromDigits_fromFloatingDigits
inferImplicit(defn.FromDigitsClass.typeRef.appliedTo(target), EmptyTree, tree.span) match {
case _: SearchSuccess =>
val summoned = untpd.TypedSplice(ref(summoner).appliedToType(target))
val fromDigits = untpd.Select(summoned, nme.fromDigits).withSpan(tree.span)
val firstArg = Literal(Constant(digits))
val otherArgs = tree.kind match {
case Whole(r) if r != 10 => Literal(Constant(r)) :: Nil
Expand Down
51 changes: 45 additions & 6 deletions docs/docs/reference/changed-features/numeric-literals.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ val y: BigInt = 0x123_abc_789_def_345_678_901
val z: BigDecimal = 111222333444.55
```
are legal by rule (2), since both `BigInt` and `BigDecimal` have `FromDigits` instances
(which implement the `FromDigits` subclasses `FromDigits.WithRadix` and `FromDigits.Decimal`, respectively).
(which implement the `FromDigits` subclasses `FromDigits.WithRadix` and `FromDigits.Floating`, respectively).
On the other hand,
```scala
val x = -10_000_000_000
Expand All @@ -70,7 +70,7 @@ the string is passed to `fromDigits`.

The companion object `FromDigits` also defines subclasses of `FromDigits` for
whole numbers with a given radix, for numbers with a decimal point, and for
numbers that can have both a decimal point and an exponent:
numbers that can have both a decimal point and an exponent, along with methods to summon a given instance of each subclass:
```scala
object FromDigits {

Expand All @@ -92,12 +92,32 @@ object FromDigits {
* exponent `('e' | 'E')['+' | '-']digit digit*`.
*/
trait Floating[T] extends Decimal[T]

/** Does `T` have a given `FromDigits[T]` instance?
*/
inline def fromDigits[T](given x: FromDigits[T]): x.type = x

/** Does `T` have a given `FromDigits.WithRadix[T]` instance?
*/
inline def fromRadixDigits[T](given x: FromDigits.WithRadix[T]): x.type = x

/** Does `T` have a given `FromDigits.Decimal[T]` instance?
*/
inline def fromDecimalDigits[T](given x: FromDigits.Decimal[T]): x.type = x

/** Does `T` have a given `FromDigits.Floating[T]` instance?
*/
inline def fromFloatingDigits[T](given x: FromDigits.Floating[T]): x.type = x
...
}
```
A user-defined number type can implement one of those, which signals to the compiler
that hexadecimal numbers, decimal points, or exponents are also accepted in literals
for this type.
A user-defined number type, that provides a given instance of one of the above subclasses, signals to the compiler
that hexadecimal numbers, decimal points, or exponents are also accepted in literals for that type. A numeric literal with a concrete type `T` and digits `digits` is replaced as follows, when a given instance for `FromDigits[T]` is available:

- `fromDigits[T].fromDigits(digits)` if the digits form a decimal whole number.
- `fromRadixDigits[T].fromDigits(digits, 16)` if the digits form a hexadecimal whole number.
- `fromDecimalDigits[T].fromDigits(digits)` if the digits are decimal with a single decimal point `"."`.
- `fromFloatingDigits[T].fromDigits(digits)` if the digits are decimal with an exponent and optionally a single decimal point `"."`.

### Error Handling

Expand All @@ -112,6 +132,25 @@ class NumberTooSmall (msg: String = "number too small") extends FromDigi
class MalformedNumber(msg: String = "malformed number literal") extends FromDigitsException(msg)
```

### Number Format Safety

A benefit of implementing one of the specialised subclasses of `FromDigits` is that the compiler preserves invariants about the format of digits passed to the `fromDigits` method.

As an example, the constructor for `BigDecimal` can not accept digits in hexadecimal format, so it does not make sense to allow hexadecimal number literals with expected type `BigDecimal`. Below is such an expression:
```scala
0xABC_123: BigDecimal
```
We can protect against this poorly formed expression by restricting the domain of acceptable number literals. The `FromDigits` companion object provides a given instance of `FromDigits.Floating[BigDecimal]`, which does not accept non-decimal literals. As described above, because the compiler can see a given instance for `FromDigits[BigDecimal]`, the hex literal is replaced with the following, after removing numeric separators:
```scala
FromDigits.fromRadixDigits[BigDecimal].fromDigits("0ABC123", 16)
```
Because the given instance for `FromDigits[BigDecimal]` in the `FromDigits` companion object is not a subtype of `FromDigits.WithRadix[BigDecimal]`, as expected by the given clause of `fromRadixDigits`, the following error is issued:
```scala
1 |0xABC_123: BigDecimal
|^
|Type BigDecimal does not have a FromDigits instance for whole numbers with radix other than 10.
```

### Example

As a fully worked out example, here is an implementation of a new numeric class, `BigFloat`, that accepts numeric literals. `BigFloat` is defined in terms of a `BigInt` mantissa and an `Int` exponent:
Expand Down Expand Up @@ -174,7 +213,7 @@ With the setup of the previous section, a literal like
```
would be expanded by the compiler to
```scala
BigFloat.FromDigits.fromDigits("1e100000000000")
FromDigits.fromFloatingDigits[BigFloat].fromDigits("1e100000000000")
```
Evaluating this expression throws a `NumberTooLarge` exception at run time. We would like it to
produce a compile-time error instead. We can achieve this by tweaking the `BigFloat` class
Expand Down
20 changes: 20 additions & 0 deletions library/src/scala/util/FromDigits.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import quoted._
import quoted.matching._
import internal.Chars.digit2int
import annotation.internal.sharable
import annotation.implicitNotFound

/** A typeclass for types that admit numeric literals.
*/
Expand All @@ -27,6 +28,7 @@ object FromDigits {
/** A subclass of `FromDigits` that also allows to convert whole number literals
* with a radix other than 10
*/
@implicitNotFound("Type ${T} does not have a FromDigits instance for whole numbers with radix other than 10.")
trait WithRadix[T] extends FromDigits[T] {
def fromDigits(digits: String): T = fromDigits(digits, 10)

Expand All @@ -39,14 +41,32 @@ object FromDigits {
/** A subclass of `FromDigits` that also allows to convert number
* literals containing a decimal point ".".
*/
@implicitNotFound("Type ${T} does not have a FromDigits instance for numbers with 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*`.
*/
@implicitNotFound("Type ${T} does not have a FromDigits instance for floating-point numbers.")
trait Floating[T] extends Decimal[T]

/** Does `T` have a given `FromDigits[T]` instance?
*/
inline def fromDigits[T](given x: FromDigits[T]): x.type = x

/** Does `T` have a given `FromDigits.WithRadix[T]` instance?
*/
inline def fromRadixDigits[T](given x: FromDigits.WithRadix[T]): x.type = x

/** Does `T` have a given `FromDigits.Decimal[T]` instance?
*/
inline def fromDecimalDigits[T](given x: FromDigits.Decimal[T]): x.type = x

/** Does `T` have a given `FromDigits.Floating[T]` instance?
*/
inline def fromFloatingDigits[T](given x: FromDigits.Floating[T]): x.type = x

/** The base type for exceptions that can be thrown from
* `fromDigits` conversions
*/
Expand Down
36 changes: 36 additions & 0 deletions tests/neg/generic-num-lits.check
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-- Error: tests/neg/generic-num-lits.scala:2:8 -------------------------------------------------------------------------
2 |val b = 0xcafebabe: BigDecimal // error
| ^
| Type BigDecimal does not have a FromDigits instance for whole numbers with radix other than 10.
-- Error: tests/neg/generic-num-lits.scala:3:8 -------------------------------------------------------------------------
3 |val c = 1.3: BigInt // error
| ^
| Type BigInt does not have a FromDigits instance for numbers with a decimal point.
-- Error: tests/neg/generic-num-lits.scala:4:8 -------------------------------------------------------------------------
4 |val d = 2e500: BigInt // error
| ^
| Type BigInt does not have a FromDigits instance for floating-point numbers.
-- Error: tests/neg/generic-num-lits.scala:7:7 -------------------------------------------------------------------------
7 | case 0xcafebabe: BigDecimal => // error
| ^
| Type BigDecimal does not have a FromDigits instance for whole numbers with radix other than 10.
-- Error: tests/neg/generic-num-lits.scala:11:7 ------------------------------------------------------------------------
11 | case 1.3: BigInt => // error
| ^
| Type BigInt does not have a FromDigits instance for numbers with a decimal point.
-- Error: tests/neg/generic-num-lits.scala:15:7 ------------------------------------------------------------------------
15 | case 2e500: BigInt => // error
| ^
| Type BigInt does not have a FromDigits instance for floating-point numbers.
-- Error: tests/neg/generic-num-lits.scala:19:7 ------------------------------------------------------------------------
19 | case 0xa => // error
| ^
| Type BigDecimal does not have a FromDigits instance for whole numbers with radix other than 10.
-- Error: tests/neg/generic-num-lits.scala:23:7 ------------------------------------------------------------------------
23 | case 1.2 => // error
| ^
| Type BigInt does not have a FromDigits instance for numbers with a decimal point.
-- Error: tests/neg/generic-num-lits.scala:27:7 ------------------------------------------------------------------------
27 | case 5e10 => // error
| ^
| Type BigInt does not have a FromDigits instance for floating-point numbers.
28 changes: 28 additions & 0 deletions tests/neg/generic-num-lits.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
val a = 0.25: BigDecimal
val b = 0xcafebabe: BigDecimal // error
val c = 1.3: BigInt // error
val d = 2e500: BigInt // error

val e = (??? : Any) match
case 0xcafebabe: BigDecimal => // error
()

val f = (??? : Any) match
case 1.3: BigInt => // error
()

val g = (??? : Any) match
case 2e500: BigInt => // error
()

val h = (1.3: BigDecimal) match
case 0xa => // error
()

val i = (1: BigInt) match
case 1.2 => // error
()

val j = (1: BigInt) match
case 5e10 => // error
()