Principles for Implicits in Scala 3

In my opinion, changing keywords from implicit to given/ implied/ whatever is just another minor tweak when it comes to beginner friendliness, albeit bringing heavy migration pain.

First I’ll link to my previous post on this subject:

Typical problem with implicits is that implicit conversion doesn’t work. Changing syntax for implicits has zero influence on that. A pretty rare and simple to fix problem is when we have to disambiguate between implicit and explicit parameter lists with explicit apply:

def method(explicit1: Int)(implicit implicit1: String): String => Int =
  x => explicit + x.length

method(5).apply("wow") // implicit1 stays implicit

Why implicit conversion doesn’t work in a particular place (but works somewhere else)?

  • because you forgot to import some implicit conversions or implicit values
  • types don’t match so implicit conversions are rejected silently
  • there are implicits ambiguities
  • you forgot to mark something implicit
  • etc

What Scala compiler reports when it can’t find a extension method coming from implicit conversion (or implicit class which is a syntactic sugar for it)?
value print is not a member of Int

What Rust compiler says?

error[E0369]: binary operation `<<` cannot be applied to type `f32`
 --> src/main.rs:6:1
  |
6 | x << 2;
  | ^^^^^^
  |
  = note: an implementation of `std::ops::Shl` might be missing for `f32`
error[E0119]: conflicting implementations of trait `main::MyTrait` for type `main::Foo`:
  --> src/main.rs:15:1
   |
7  | impl<T> MyTrait for T {
   | --------------------- first implementation here
...
15 | impl MyTrait for Foo { // error: conflicting implementations of trait
   | ^^^^^^^^^^^^^^^^^^^^ conflicting implementation for `main::Foo`

I wrote an application using Rust. Here’s very simplified case from it.
In one file I have:

pub trait FixedPoint where Self: Sized {
  ...
}
pub trait FixI32: FixedPoint<Raw=i32> {
  ...
}
impl<T: FixedPoint<Raw=i32>> FixI32 for T {}

In second file I have:

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct NoFractI32(i32);

impl FixedPoint for NoFractI32 {
  ...
}

In third file I forgot to import relevant typeclasses (signalled by commenting out):

// use demixer::fixed_point::{FixedPoint, FixI32, FixU32};
use demixer::fixed_point::types::{NoFractI32, Log2D};

#[test]
fn initial_cost_corresponds_to_one_bit_costs_series() {
  ...
  let new_tracker = old_tracker.updated(NoFractI32::ONE.to_fix_i32());
  ...
}

What Rust compiler says?

error[E0599]: no method named `to_fix_i32` found for type `demixer::fixed_point::types::NoFractI32` in the current scope
  --> tests/cost_tracking.rs:33:59
   |
33 |     let new_tracker = old_tracker.updated(NoFractI32::ONE.to_fix_i32());
   |                                                           ^^^^^^^^^^
   |
   = help: items from traits can only be used if the trait is in scope
help: the following trait is implemented but not in scope, perhaps add a `use` for it:
   |
20 | use demixer::fixed_point::FixI32;
   |

Plus similar error message about FixedPoint. Following compiler suggestions correctly solves the problem with missing imports (uses in Rust parlance).

There’s a huge discrepancy between scalac error messages and rustc error messages. Usefulness of Rust’s error messages is IMO the main reason Rust is so liked.

In Scala we don’t have the simple rules and useful error messages that Rust has. Instead:

  • Scala has very complicated prioritized implicit resolution, while Rust just rejects any ambiguities
  • typeclasses instances in Scala can be defined anywhere, while Rust rejects orphans
  • typeclasses instances in Scala can be stored in anything, while Rust allows only global definitions
  • Scala just says that a method is not a member of some type and doesn’t even try to suggest any solution while Rust gives ready to copy-and-paste code snippet that usually solves the problem

Rust also has more simplifications compared to Scala, e.g. Rust doesn’t have method overloading: Justification for Rust not Supporting Function Overloading (directly) - Rust Internals

Complex implicit conversions/ classes/ whatever are done almost exclusively by library writers. Library user’s job is usually to have proper implicits imported into scope. Implicits defined by ordinary Scala programmers are usually simple, like implicit correlation ID or implicit ExecutionContext.

Providing useful suggestion in compilation errors for all existing code may be unfeasible now, but when Scala compiler starts giving suggestions to fix missing implicits in scope then library writers will reorganize their libraries to make the compiler suggestions more useful. Rust compiler was designed to provide useful error messages from the start (i.e. from the first public release, I think), so if we want to compete with Rust in this area we must make useful error messages a core feature of the compiler.

Rust’s syntax related to typeclasses wasn’t perfect either, but that didn’t give Rust bad PR. Some explanation: Redirecting...

Using just the trait name for trait objects turned out to be a bad decision. The current syntax is often ambiguous and confusing, even to veterans, and favors a feature that is not more frequently used than its alternatives, is sometimes slower, and often cannot be used at all when its alternatives can.

To summarize:
Typical problem with implicits is not the syntax, but figuring out what to import to have required extension methods available on type.

6 Likes