macro summum-types

A sum-type macro crate with all the conversions, accessors, and support for abstract methods across variants, and interoperability between sum-types

5 releases

0.1.4 Mar 18, 2024
0.1.3 Mar 17, 2024
0.1.2 Mar 17, 2024
0.1.1 Mar 17, 2024
0.1.0 Mar 16, 2024

#762 in Rust patterns

Download history 1/week @ 2024-06-29 13/week @ 2024-07-06 3/week @ 2024-09-21 50/week @ 2024-09-28

53 downloads per month

MIT/Apache

47KB
672 lines

summum-types

A sum-type macro crate with all the conversion, accessors and support for abstract methods across variants, and interoperability between sum-types

Summary

This crate strives to provide dynamic runtime-resolving types on top of Rust’s static compile-time types, with full support for generics, lifetimes, visibility, etc.

The summum macro allows for easy declaration of sum-types that:

  • come with all the traits and methods you'd expect for conversion and access
  • allow generic method implementation across all variants
  • support interoperability across multiple types via shared variant names

Motivation

Rust's enums are already sum types, so why do I need this crate?

Lots and lots of boilerplate written for you.

I realized I needed something like this when I tried to implement a recursive type definition. Rust's static type system could not represent the type that I needed without imposing a finite recursion depth. But using an enum doubled the size of my implementation because monomorphization across the variants wasn't supported.

Summum??

It'a just a dumb pun. It means "highest" in Latin. No connection whatsoever to the pyramid people in Utah.

Usage

Define a sum type inside the summum macro, but otherwise it's just like any other enum:

summum!{
    #[derive(Debug, Clone)]
    enum SliceOrPie<'a, T> {
        Slice(&'a [T]),
        Vec(Vec<T>),
    }
}

And you automatically get all the accessors you'd want¹:

  • From impl to create the sum type from any of each of its variants
  • TryFrom impl, to convert the sum type back to any of its variants.²
  • pub fn is_*t*(&self) -> bool
  • pub fn try_as_*t*(&self) -> Option<&T>
  • pub fn as_*t*(&self) -> &T
  • pub fn try_as_mut_*t*(&mut self) -> Option<&mut T>
  • pub fn as_mut_*t*(&mut self) -> &mut T
  • pub fn try_into_*t*(self) -> Result<T, Self>
  • pub fn into_*t*(self) -> T
  • pub fn variant_name(&self) -> &'static str
  • pub fn SumT::variants() -> &[&str]

Note: *t* is a lower_snake_case rendering of the variant identifier, and SumT is the type you defined

¹If you want more accessors (or features in general), please email me
²Except where the variant type would be an "uncovered" generic as described here

Generic method impl dispatch

You can also add method impl blocks, to implement functionality shared across every variant within your sum-type. This expands to a match statement on &self, where &self is remapped to a local variable of the inner variant type. For example:

summum!{
    #[derive(Debug, Clone)]
    enum SliceOrPie<'a, T> {
        Slice(&'a [T]),
        Vec(Vec<T>),
    }

    impl<'a, T> SliceOrPie<'a, T> {
        fn get(&self, idx: usize) -> Option<&T> {
            self.get(idx)
        }
    }
}

You can also use InnerT as a local type alias that expands to the variant's inner type. Self will continue to refer to the whole outer type. If your method returns Self, you'll need to remember to use the .into() conversion to get back to the sum-type. Like this:

summum!{
    enum Num {
        F64(f64),
        I64(i64),
    }

    impl Num {
        fn max_of_type(&self) -> Self {
            InnerT::MAX.into()
        }
    }
}

Yes, all abstract methods need self to know which variant type to use. You can also use a Variant Specialized Method (keep reading...) for constructors and other places where you don't want a self argument.

Of course you can also implement ordinary methods on the sub-type outside the summum invocation, where these behaviors don't apply.

Variant Specialized Methods

Sometimes you need to generate an explicit method for each variant. summum has you covered. Just end a method name with "_inner_var" and it will be replaced by a separate method for each variant. For example, the code below will lead to the generation of a the max_f64 and max_i64 methods.

summum!{
    enum Num {
        F64(f64),
        I64(i64),
    }

    impl Num {
        fn max_inner_var() -> Self {
            InnerT::MAX.into()
        }
    }
}

You can also pass self as an argument to variant-specialized methods. Be warned, however, if the inner type of self doesn't agree with the method variant then the method will panic!

Within a variant-specialized method, you can use InnerT in the function signature, for both arguments and the method return type. For example:

    //Within the `summum` invocation above...

    impl Num {
        fn multiply_add_one_inner_var(&self, multiplier: InnerT) -> InnerT {
            *self * multiplier + 1 as InnerT
        }
    }

Polymorphism

One of the uses for sum-type enums is to fill a similar role to dyn trait objects in polymorphic method dispatch. Sum-type enums provide different design constraints, such as being Sized and not requiring object safety. Unlike the Any trait in particular, summum types provide a method to recover ownership of the original type, and allowing internal lifetimes (no 'static bound).

Sum-types are certainly not a replacement for dynamic dispatch in every case, but hopefully they will be another tool to reach for when it's convenient.

Variant Substitution in Method Calls for Interoperation Across Types

Consider multiple types that interact with each other like in the example below. Sometimes we need to interact with a related type in a way that depends on which variant we're generating. In those cases, we can call the synthesized variant-specific functions of other types, as long as the variant names of the impl type are a superset of the type being called.

summum!{
    enum Num {
        F64(f64),
        I64(i64),
    }

    enum NumVec {
        F64(Vec<f64>),
        I64(Vec<i64>),
    }

    impl NumVec {
        fn push(&mut self, item: Num) {
            // This will be replaced with either `into_f64` or `into_i64`,
            // depending on the variant branch being generated
            let val = item.into_inner_var();
            self.push(val);
        }
    }
}

Restrict and Exclude Control Directives

Sometimes a branch of a conditional just doesn't make sense within the context of some variants, and the code in the branch will never be executed. Unneeded code is bad, but it's really really bad if errors in that grabage code prevent the rest of the project from compiling.

Enter the exclude! and restrict! directives. You can use them to say, "These variants will never get here" (exclude) or "Only these variants will ever get here" (restrict).

These control directives may be used to remove entire variants, but they may also be used within narrower runtime scopes. A restrict! or exclude! directive effectively removes the rest of the code in the Block (all code until the end of the scope at the '}' brace) but doesn't affect other code in the method.

Here is an example:

summum!{
    enum Num {
        F64(f64),
        I64(i64),
    }

    impl Num {
        fn multiply_int_only(&self, other: i64) -> Self {
            summum_restrict!(I64);
            (*self * other).into()
        }
        fn convert_to_float_without_rounding(&self) -> f64 {
            if *self > i32::MAX as InnerT {
                summum_exclude!(I64, ); //You can supply multiple variants
                *self as f64
            } else {
                *self as f64
            }
        }
    }
}

Bonus Syntax: Haskell / TypeScript Style

If you're into the whole brevity thing, you can write:

summum!{
    type Num = f64 | i64;
}

You can use the as keyword to rename variants using this syntax:

summum!{
    type VecOrVRef<'a, V> = &'a Vec<V> as Vec | 
                            &'a V as V;
}

Limitations

  • This macro can generate a lot of code, most of which will be eliminated as dead. If overused, this might result in degraded build times. Also summum sum-types are probably not appropriate for exposing in a public API, but YMMV.

  • impl blocks must be in the same summum! macro invocation where the types are defined. This is the primary reason summum is not an attrib macro. The limitation is due to this issue and the work-around¹ is likely more fragile and a worse experience than just keeping the impls together.

  • Each inner type should occur only once within a sum-type. The purpose of this crate is runtime dynamism over multiple types. If you want to create multiple variants backed by the same type, then you could define type aliases. Or try typesum by Natasha England-Elbro.

¹It's possible to implement the macro expansion in two passes where the second macro is created on the fly, folding in information from the source code. But it's a bit of a Rube Goldberg machine.

Future Work

Abstract Method Declarations

In the vein of polymorphic method dispatch, I'd like to support "trait style" method declarations without a body. It's just syntactic sugar over the existing abstract impl dispatch, but it would make the declaration of an abstract sum-type with methods look much cleaner.

Associated Types for Each Variant

I'd like to add support for accessing the type of each variant through an associated type alias. So relative to the Num example above, the declaration would also include type F64T = f64. What's the point of that? By itself, not much. But combine that with the ability for another type's implementation to reference this type via shared variants, using the ::VariantT type alias, and you can do this:

summum!{
    enum Num {
        F64(f64),
        I64(i64),
    }

    enum NumVec {
        F64(Vec<f64>),
        I64(Vec<i64>),
    }

    impl NumVec {
        fn push_inner_var(&mut self, item: Num::VariantT) {
            self.push(item)
        }
        fn get_or_default(&self, idx: usize) -> Num {
            self.get(idx).cloned().unwrap_or_else(|| Num::VariantT::default() ).into()
        }
    }
}

This feature is currently disabled on account of this issue. Hopefully this will reach stable soon and I can re-enable it.

Future Plan for Accessors

I'd like to implement generic accessors, along the lines of: pub fn try_into_sub<T>(self) -> Option<T>, for example. This would eliminate the annoyance of remembering/ guessing what identifier is assigned to a particular variant. Unfortunately that seems to be blcoked on this issue for the time being.

Acknowledgement & Other Options

Several other union type / sum type crates exist, and one of them might be better for your use case. Each has things they do uniquely well and I took inspiration from all of them.

  • typeunion by Antonius Naumann is great if you want something lightweight, and it has an sweet supertype feature for automatic conversions between types with variants in common.
  • sum_type by Michael F. Bryan is no-std and manages to do everything with declarative macros. Also it supports downcasting for variant types that can implement the Any trait.
  • typesum by Natasha England-Elbro is awesome for the control it gives you over the generated output and the way it supports overlapping base types beautifully. It'll cope much better if you plan to have a silly number of variants.

Finally, thank you for looking at this crate. If you have ideas and/or feedback, please let me know, either via email or with a GitHub issue.

Dependencies

~285–740KB
~17K SLoC