LanguagesArchitecture

Something unexpected happened, once we added const generics to the 0.2 beta.

We discovered that by enabling passing functions as generic parameters, we also effectively enabled concepts, a way to specify constraints on parameters, without making them implement any traits.

Behold, a Concept Function!

For example, let's say we already have a Ship struct and a calcDamage function:

vale
struct Ship {
strength int;
}
func calcDamage(ship &Ship, target &Ship) int {
ship.strength - target.strength
}

Now we want a battle function, which can take any type as long as it has a calcDamage function:

vale
func battle<T>(attacker T, defender T)
where func calcDamage(&T, &T)int
{
damage = calcDamage(attacker, defender);
// ... other logic
return damage;

}

There it is! That where func calcDamage(&T, &T)int specifies that there must be a calcDamage function that takes in two &Ts.

The only mainstream language today which can accomplish something like this is C++, with its requires requires clause: 0

c++
template<typename T>
requires requires(T a) { { calcDamage(&a, &a) } -> std::same_as<int>; }
int battle(T* attacker, T* defender) {
    int damage = calcDamage(attacker, defender);
    // ... other logic
    return damage;
}

As you can see, Vale makes it wonderfully easy to use this approach.

We already use it thoroughly in our standard library. For example, in optutils.vale, we have a function Opt<T>.clone that's only enabled if T also has a clone method:

vale
func clone<T>(self &Opt<T>) Opt<T>
where func clone(&T)T
{
...
}

This can be a lot easier, compared to previous approaches.

Previous Approaches, Traits and Interfaces

For example, in Java or C# or Rust, if we had a pre-existing Ship and calcDamage function, we would have to make an interface (or trait) to describe the bounds and then require all callers to to make their arguments extend that interface (or make an impl, in Rust's case) for every type that they want to supply.

Here's an example in Rust:

rs
// Let's say we had a pre-existing function and a struct...
struct Ship {
  strength: i32
}
fn calcDamage(ship: &Ship, target: &Ship) -> i32 {
  return ship.strength - target.strength;
}

// And we want a function that can take any type.
// We'll need a trait for the functions we want to call on it.
trait Fireable {
  fn calcDamage(&self, target: &Self) -> i32;
}
fn battle<T: Fireable>(attacker: &T, defender: &T) -> i32 {
  let damage = attacker.calcDamage(defender);
  // ... other logic
  return damage
}

// And the caller must make an `impl` for every type we want to feed into `battle`
impl Fireable for Ship {
    fn calcDamage(&self, target: &Self) -> i32 {
        // (Can optionally inline this, if this is the only usage.)
        return calcDamage(&self, &target);
    }
}

If we can't modify the existing type (such as if it's defined by a third-party library) we sometimes need to make a wrapper class (sometimes known as a typeclass or a newtype) which can implement the required interface.

Unexpected Déjà Vu from C

After using this for a few weeks, I had a shocking realization: this is similar in spirit to how we did things in C!

This snippet in Vale...

vale
struct Ship {
strength int;
}
func calcDamage(ship &Ship, target &Ship) int {
ship.strength - target.strength
}


func battle<T>(attacker T, defender T)
where func calcDamage(&T, &T)int
{
damage = calcDamage(attacker, defender);
// ... other logic
return damage;

}

...would be this in C:

c
struct Ship {
  int strength;
};
int calcDamage(Ship* ship, target &Ship) {
  return ship.strength - target.strength;
}

typedef int (*Fire)(void*, void*);
int battle(
    void* attacker,
    void* defender,
    Fire calcDamage) {
  int damage =
      calcDamage(attacker, defender);
  // ... other logic
  return damage;
}

The only real difference is that Vale passes the calcDamage function in at compile-time, and C passes it in at run-time via a function pointer. Aside from that, these approaches are the same.

Note how neither requires that calcDamage be a method of the type, they can be free functions. I think this is a much cleaner approach, that allows us to decouple the type from the functions we use on it.

That's all!

Thanks for reading, we hope you enjoyed this article! And if you're impressed with our track record and believe in the direction we're heading, please consider sponsoring us on github:

With your support, we can bring easier generics to programmers worldwide!

Side Notes
(interesting tangential thoughts)
0

Nim can do this too! See Nim's concepts, plus an ongoing redesign. Thank you ZoomRmc!

We're looking for sponsors!

With your help, we can launch a language with speed, safety, flexibility, and ease of use.

We’re a very small team of passionate individuals, working on this on our own and not backed by any corporation.

If you want to support our work, please consider sponsoring us on GitHub!

Those who sponsor us also get extra benefits, including:

  • Early access to all of our articles!
  • A sneak peek at some of our more ambitious designs, such as memory-safe allocators based on algebraic effects, an async/await/goroutine hybrid that works without data coloring or function coloring, and more.
  • Your name on the vale.dev home page!

With enough sponsorship, we can:

  • Start a a 501(c)(3) non-profit organization to hold ownership of Vale. 1
  • Buy the necessary computers to support more architectures.
  • Work on this full-time.
  • Make Vale into a production-ready language, and push it into the mainstream!

We have a strong track record, and during this quest we've discovered and implemented a lot of completely new techniques:

  • The Linear-Aliasing Model that lets us use linear types where we need speed, and generational references where we need the flexibility of shared mutability.
  • Region Borrowing, which makes it easier to write efficient code by composing shared mutability with the ability to temporarily freeze data.
  • Higher RAII, where the language adds logic safety by enforcing that we eventually perform a specific future operation.
  • Perfect Replayability makes debugging race conditions obsolete by recording all inputs and replaying execution exactly.

These have been successfully prototyped. With your sponsorship we can polish them, integrate them, and bring these techniques into the mainstream. 2

Our next steps are focused on making Vale more user-friendly by:

  1. Finalizing the compiler's error messages and improving compile speeds.
  2. Polishing interop with other languages.
  3. Growing the standard library and ecosystem!

We aim to combine and add to the benefits of our favorite languages:

We need your help to make this happen!

If you're impressed by our track record and believe in the direction we're heading, please consider sponsoring us:

If you have any questions, always feel free to reach out via email, twitter, discord, or the subreddit. Cheers!

1

Tentatively named the Vale Software Foundation.

2

Generational references, the linear-aliasing model, and higher RAII are all complete, and region borrowing, fearless FFI, and perfect replayability have been successfully prototyped. Be sure to check out the experimental version of the compiler!