5
$\begingroup$

I am investigating ways to add some form of abstract data types to a toy language.

I was reading Graydon Hoare's The Rust I Wanted Had No Future, in which he talks about existential types. I have not used a language with existentials, but he seem to write about them as an alternative to traits, as a way to have abstract data types.

He writes this about traits:

Traits. I generally don't like traits / typeclasses. To my eyes they're too type-directed, too global, too much like programming and debugging a distributed set of invisible inference rules, and produce too much coupling between libraries in terms of what is or isn't allowed to maintain coherence.

And about existentials:

Underpowered existentials. The dyn Trait mechanism in Rust allows runtime and heterogeneous polymorphism, a.k.a. Existentials. These types are useful for many reasons: both solving heterogeneous representation cases and also selectively backing-off from monomorphization or inlining (when you have those) in order to favour code size / compile time or allow dynamic linking or runtime extension.

He also notes existentials in the section about Missing Errors:

the throws syntax, the dedicated error-carrying ABI, and the not-necessarily-allocating existential Error type in Swift would have all helped to fill in this "explicit error union" model.

I get the impression that existentials are more like Interfaces in Go ─ that is, structural, as opposed to nominal. Traits in Rust are more powerful, and can contain default implementations for methods as well as act as trait bounds on generics.

How are existentials different from Rust's traits?

$\endgroup$
1
  • 1
    $\begingroup$ If you have ever used a “dynamically-typed” language, you have used a language with existentials. A “dynamic type” is just $\sum_{T:\mathsf{type}} T$, which is an existential. $\endgroup$ Commented Nov 30, 2024 at 11:17

3 Answers 3

5
$\begingroup$

In Rust, "traits" and "types" are entirely separate. You cannot write struct Foo(SomeTrait) or fn bar(arg: SomeTrait)[1]. A trait can be converted into a type in three ways:

  • Bounded type parameter: <E: Trait>, then E is a type
  • Trait object: dyn Trait
  • Existential type: impl Trait

A trait consists of types, constants, and functions, which are called associated items. Every type that implements the trait does so by providing a definition for each associated item.[2] For example, a trait contains a function signature, and every type that implements the trait defines a function with the same signature and a different body.

An existential type is a type that implements a particular trait and is opaque. What this means is roughly, you can call the trait's methods and use its other associated items on the instance, and pass the instance to functions/constructors that take a generic or existential type that implements said trait or a supertrait (or functions/constructors that take the exact same existential), but nothing else.

// A trait
trait SomeTrait {
  // An associated function
  fn value(&self) -> u32;
}

// A type
struct Foo(&'static str);
// Another type
struct Bar(u32);

// An implementation. This one makes it so `Foo` implements `SomeTrait`
impl SomeTrait for Foo {
  // Same signature as in `SomeTrait`: `fn value(&self) -> u32`
  fn value(&self) -> u32 {
    self.0.len() as u32
  }
}

// This implementation makes it so `Bar` implements `SomeTrait`
impl SomeTrait for Bar {
  // Same signature: `fn value(&self) -> u32`
  fn value(&self) -> u32 {
    // Different body
    self.0
  }
}

// This function returns an existential type.
fn helper() -> impl SomeTrait {
  Foo("Hello")
}

fn main() {
  let foo = helper();
  // Even though at runtime, `foo` is guaranteed to be type `Foo`,
  // the type returned by `helper` is "opaque" in the sense that we
  // cannot use it like so. The following would be a compiler error:
  // > println!("{}", foo.0);
  // We can only use it like any other type that implements `SomeTrait`;
  // this means we can only call `foo.value()`
  println!("{}", foo.value());  // Prints "5"
}

More formally, every existential type O that implements trait T has a corresponding precise type P (which may itself be existential, or is an implicit type parameter if the existantial type is in a parameter). There's a limited scope[3] in which you P and O can be used interchangeably, and instances of one can be assigned to places that expect the other. Outside of that scope, instances of P and O have no relation, and O can only be used as a type with a hidden structure that implements trait T


[1] You could write these prior to Rust 2021, but traits and types were still separate. Trait in type position was just syntax for a trait object (equivalent to today's dyn Trait), and this confusion is partly why it was deprecated and replaced by dyn Trait.

[2] Except associated items with default definitions. If a type doesn't provide an definition for one of those, it uses its default definition.

[3] The "limited scope" depends on the existential type's occurrence's location: whether the existential type is a parameter type, return type, let-binding type annotation, associated type definition, or type alias.

// Existential parameter: you can pass any type implementing `SomeTrait` to it,
// but inside the function body it's opaque.
fn foo(a: impl SomeTrait, i: usize) -> u32 {
  if i == 0 {
    a.value()
  } else {
    3 + foo(a, i - 1)
  }
}

fn use_foo() {
  println!("{}", foo(Foo("Hello"), 4));  // Prints "17"
  println!("{}", foo(Bar(5), 10));  // Prints "35"
}

// Existential return type: you can return any type implementing `SomeTrait`,
// but outside the function body it's opaque.
// Also, you cannot return different types in the same function
// (e.g. `if (i == 0) { Foo("Hello") } else { Bar(i) }` is illegal).
fn bar(i: u32) -> impl SomeTrait {
  Bar(i * 10)
}

fn use_bar() {
  println!("{}", bar(10).value());  // Prints "100"
  println!("{}", foo(bar(20), 4));  // Prints "212"
}

fn baz() {
  // Existential `let`-binding type annotation: you can assign any type,
  // but the bound identifier is opaque.
  // Also, you cannot assign different types in the same `let` binding.
  // Requires `#![feature(impl_trait_in_bindings)]`, which as of Rust 1.82 is not implemented,
  // so the following actually won't compile:
  // > let x: impl SomeTrait = Foo("Hello");
  // But we can get almost the same behavior with the following code,
  // which itself requires `#![feature(type_alias_impl_trait)]`
  type X = impl SomeTrait;
  let x: X = Foo("Hello");

  // The following is invalid:
  // > println!("{}", x.0);
  // This is OK:
  println!("{}", x.value());  // Prints "5"
  // As is this:
  println!("{}", foo(x, 10));  // Prints "35"
}

struct Baz(u32);
impl Iterator for Baz {
  // Existential associated type: inside the `impl` block it's a precise type,
  // outside the `impl` block it's opaque.
  // It's also required in order to define an associated type to be an unnamed existential;
  // I cannot define `Item` to be anything else or `next` won't compile.
  // Requires `#![feature(impl_trait_in_assoc_type)]`
  type Item = impl SomeTrait;

  fn next(&mut self) -> Option<Self::Item> {
    if self.0 == 0 {
      return None;
    }
    let i = self.0;
    self.0 -= 1;
    
    // Here's an example where existential types are required:
    // the type we return is an existential type.
    Some(bar(i))
  }

  // (Again, if there were other `Self::Item`s within the `impl` block,
  // they would all have to be the same type,
  // which in this case is a return value of a call to `bar`.)
}

mod qux {
  use super::*;

  // Existential type alias: inside the module it's a precise type,
  // outside the module it's opaque.
  // It's also required in order to use an unnamed existential in a named position without making it a generic;
  // I cannot define `T` to be anything else or `Ts` won't compile.
  // Requires `#![feature(type_alias_impl_trait)]`
  pub type T = impl SomeTrait;

  pub struct Ts {
    pub x: T,
    pub y: T
  }

  pub fn ts() -> Ts {
    Ts {
      x: Baz(100).next().unwrap(),
      y: Baz(200).next().unwrap()
    }
  }
}

fn use_qux() {
  let ts = qux::ts();
  println!("{}", ts.x.value());  // "1000"
  println!("{}", ts.y.value());  // "2000"
}
```
$\endgroup$
6
$\begingroup$

You seem to have misunderstood the blog post. Existentials aren't an alternative to traits, and existentials aren't comparable with traits. It's a category error.

Rust does have existentials! dyn Trait "trait objects" and impl Trait "abstract types" are its notion of existential type. Compare GHC's ExistentialQuantification extension:

dyn Trait
forall a. Typeclass a => a

Hoare is merely complaining that dyn and impl are underpowered and underutilized/overly discouraged. This has nothing to do with his personal distaste for the trait system itself and for typeclass systems in general.

(A better Rust programmer than I may be able to elucidate his complaint about Rust's existentials in more detail--though I do notice that one lacking feature is the ability to existentially quantify generic type variables--but I believe this answers your question.)

$\endgroup$
2
  • 1
    $\begingroup$ Note that Rust's impl Trait syntax is sometimes also described as an "existential type," and it does have many seemingly arbitrary restrictions on where it may appear. $\endgroup$ Commented Oct 23, 2024 at 6:44
  • 3
    $\begingroup$ @Kevin: Most restrictions on where impl Trait may appear are "temporary", there is a will to lift them, but it requires significant engineering effort, and thus will take time. $\endgroup$ Commented Oct 23, 2024 at 12:28
3
$\begingroup$

You can encode traits as existentials, but I don't think this approach is helpful whatsoever

The canonical example for existential types are ML modules (note however that OCaml extensions to ML-type module systems extend it beyond existential types), and traits/type classes are somehow similar to ML modules, but with a few important differences.

Consider this ML (OCaml) code:

module type Ord =
  sig
    type 'a 
    val leq : 'a -> 'a -> bool
  end

This is an existential type: it could be written as $\exists \tau. \tau \rightarrow \tau \rightarrow \mathbf{2}$. $\tau$ is an abstract type

Now, there are two ways we could use this type:

  • given a function of type that can be written as $\alpha \to \alpha \to \mathbf{2}$ (where $\alpha$ is a type variable), i.e. fulfills the existential type, we may pack this value into a module such that it has existential type (i.e., $\alpha$ is now abstract and invisible)
  • given a value of this type (i.e., a module satisfying the signature Ord), we can unpack this value to use the value (in this case, the function $\leq$) abstracted within the module, without the programmer needing to know the implementation.

For example, if I'm writing a red-black tree, all I need to know is that the keys are comparable with each other. In the case of string, there may be many reasonable ways to compare them: e.g. in lexicographic order, by length, or otherwise. I don't need to know how it is implemented, yet I can still write code that is correct with regard to the implementation (and, in fact, all correct implementations).

A trait or type class is basically like a signature, except there could be only one instance (implementation). I can write something similar in Haskell:

class Ord a where
  (<=) :: a -> a -> Bool

It looks similar to a ML signature, right? But there could only be one instance of Ord a for each a! (There are some GHC extensions that allow one to violate this rule, but even if you have multiple, you don't get to choose which yourself, so this is generally considered bad practice.)

So it doesn't really have the same expressive power as ML modules and signatures. Existential types expand the expressive power of ML (i.e., allow previously unexpressible programs to be expressible), but type classes don't really. They can be thought of and implemented as a form of search and record passing: i.e., whenever you write <=, the compiler determines the type of the arguments (say T), search the type class database for an implementation of Ord for T (which is encoded as a record), and implicitly passes the record to your function. While it is more hands-off and automatic than modules and existentials, it does not provide the same level of expressive power that modules do. You won't be able to reasonably implement a counter, for example, using type classes.

So while it is possible to think of them as existentials, it is best not to, as it lacks the expressiveness of existentials.

Recently, there has been a line of work in OCaml, modular implicits, that allow one to combine the best of two worlds: maintaining the versatility and power of modules, while allowing hands-free coding by allowing one to pass modules implicitly. However, the implementation work has been stagnating for a few years, so I wouldn't expect them to be here soon.

$\endgroup$

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.