Crates.io | summum-types |
lib.rs | summum-types |
version | 0.1.4 |
source | src |
created_at | 2024-03-16 07:12:48.447275 |
updated_at | 2024-03-18 02:29:22.759933 |
description | A sum-type macro crate with all the conversions, accessors, and support for abstract methods across variants, and interoperability between sum-types |
homepage | |
repository | https://github.com/luketpeterson/summum-types |
max_upload_size | |
id | 1175468 |
size | 47,293 |
A sum-type macro crate with all the conversion, accessors and support for abstract methods across variants, and interoperability between sum-types
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:
Rust's
enum
s 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.
It'a just a dumb pun. It means "highest" in Latin. No connection whatsoever to the pyramid people in Utah.
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¹:
impl
to create the sum type from any of each of its variantsimpl
, 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
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.
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
}
}
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.
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);
}
}
}
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
}
}
}
}
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;
}
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.
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.
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.
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.
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.
no-std
and manages to do everything with declarative macros. Also it supports downcasting for variant types that can implement the Any
trait.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.