methods-enum

Crates.iomethods-enum
lib.rsmethods-enum
version0.3.2
sourcesrc
created_at2022-06-12 20:49:49.830394
updated_at2023-08-07 14:22:30.573852
descriptionTwo macros for easy implementation of 'state' design pattern and other dynamic polymorphism using enum instead of dyn Trait
homepage
repositoryhttps://github.com/vvshard/methods-enum
max_upload_size
id604749
size88,612
Vladimir Shardinov (vvshard)

documentation

https://docs.rs/methods-enum

README

crates.io Docs.rs

State design pattern and other dynamic polymorphism are often solved with dyn Trait objects.

enum-matching is simpler and more efficient than Trait objects, but using it directly in this situation will "smear" the state abstraction over interface methods.

The proposed macros impl_match!{...} and #[gen(...)] provide two different ways of enum-matching with a visual grouping of methods by enum variants, which makes it convenient to use enum-matching in state design pattern and dynamic polymorphism problems.


impl_match! macro

This is an item-like macro that wraps a state enum declaration and one or more impl blocks, allowing you to write match-expressions without match-arms in the method bodies of these impl, writing the match-arms into the corresponding enum variants.

Usage example

Chapter 17.3 "Implementing an Object-Oriented Design Pattern" of the rust-book shows the implementation of the state pattern in Rust, which provides the following behavior:

pub fn main() {
    let mut post = blog::Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());
    post.request_review(); // without request_review() - approve() should not work
    post.approve();  
    assert_eq!("I ate a salad for lunch today", post.content());
}

By setting in Cargo.toml:

[dependencies]
methods-enum = "0.3.2"

this can be solved, for example, like this:

mod blog {
    pub struct Post {
        state: State,
        content: String,
    }

    methods_enum::impl_match! {

    impl Post {
        pub fn add_text(&mut self, text: &str)  ~{ match self.state {} }
        pub fn request_review(&mut self)        ~{ match self.state {} }
        pub fn approve(&mut self)               ~{ match self.state {} }
        pub fn content(&mut self) -> &str       ~{ match self.state { "" } }

        pub fn new() -> Post {
            Post { state: State::Draft, content: String::new() }
        }
    }

    pub enum State {
        Draft:          add_text(text)   { self.content.push_str(text) }
                        request_review() { self.state = State::PendingReview },
        PendingReview:  approve()        { self.state = State::Published },
        Published:      content()        { &self.content }
    }

    } // <-- impl_match!
}

All the macro does is complete the unfinished match-expressions in method bodies marked with ~ for all enum variants branches in the form:
(EnumName)::(Variant) => { match-arm block from enum declaration }.
If a {} block (without =>) is set at the end of an unfinished match-expressions, it will be placed in all variants branches that do not have this method in enum:
(EnumName)::(Variant) => { default match-arm block }.
Thus, you see all the code that the compiler will receive, but in a form structured according to the design pattern.

rust-analyzer1 perfectly defines identifiers in all blocks. All hints, auto-completions and replacements in the IDE are processed in match-arm displayed in enum as if they were in their native match-block. Plus, the "inline macro" command works in the IDE, displaying the resulting code.

Other features

  • You can also include impl (Trait) for ... blocks in a macro. The name of the Trait (without the path) is specified in the enum before the corresponding arm-block. Example with Display - below.

  • An example of a method with generics is also shown there: mark_obj<T: Display>().
    There is an uncritical nuance with generics, described in the documentation.

  • @ - character before the enum declaration, in the example: @enum Shape {... disables passing to the enum compiler: only match-arms will be processed. This may be required if this enum is already declared elsewhere in the code, including outside the macro.

  • If you are using enum with fields, then before the name of the method that uses them, specify the template for decomposing fields into variables (the IDE1 works completely correctly with such variables). The template to decompose is accepted by downstream methods of the same enumeration variant and can be reassigned. Example:

methods_enum::impl_match! {

enum Shape<'a> {
//     Circle(f64, &'a str),                  // if you uncomment or remove these 4 lines 
//     Rectangle { width: f64, height: f64 }, //    it will work the same
// }
// @enum Shape<'a> {
    Circle(f64, &'a str): (radius, mark)
        zoom(scale)    { Shape::Circle(radius * scale, mark) }      // template change
        fmt(f) Display { write!(f, "{mark}(R: {radius:.1})") };     (_, mark)
        mark_obj(obj)  { format!("{} {}", mark, obj) };             (radius, _)
        to_rect()      { *self = Shape::Rectangle { width: radius * 2., height: radius * 2.,} }
    ,
    Rectangle { width: f64, height: f64}: { width: w, height}
        zoom(scale)    { Shape::Rectangle { width: w * scale, height: height * scale } }
        fmt(f) Display { write!(f, "Rectangle(W: {w:.1}, H: {height:.1})") }; {..}
        mark_obj(obj)  { format!("⏹️ {}", obj) }
}
impl<'a> Shape<'a> {
    fn zoom(&mut self, scale: f64)                      ~{ *self = match *self }
    fn to_rect(&mut self) -> &mut Self                  ~{ match *self {}; self }
    fn mark_obj<T: Display>(&self, obj: &T) -> String   ~{ match self }
}

use std::fmt::{Display, Formatter, Result};

impl<'a> Display for Shape<'a>{
    fn fmt(&self, f: &mut Formatter<'_>) -> Result      ~{ match self }
}

} // <--impl_match!

pub fn main() {
    let mut rect = Shape::Rectangle { width: 10., height: 10. };
    assert_eq!(format!("{rect}"), "Rectangle(W: 10.0, H: 10.0)");
    rect.zoom(3.);
    let mut circle = Shape::Circle(15., "⭕");
    assert_eq!(circle.mark_obj(&rect.mark_obj(&circle)), "⭕ ⏹️ ⭕(R: 15.0)");
    // "Rectangle(W: 30.0, H: 30.0)"
    assert_eq!(circle.to_rect().to_string(), rect.to_string());
}
  • Debug flags. They can be placed through spaces in parentheses at the very beginning of the macro,
    eg: impl_match! { (ns ) ...
    • flag ns or sn in any case - replaces the semantic binding of the names of methods and traits in enum variants with a compilation error if they are incorrectly specified.

    • flag ! - causes a compilation error in the same case, but without removing the semantic binding.

Links


gen() macro

The macro attribute is set before an individual (non-Trait) impl block. Based on the method signatures of the impl block, it generates: enum with parameters from argument tuples and generates {} bodies of these methods with calling the argument handler method from this enum.
This allows the handler method to control the behavior of methods depending on the context, including structuring enum-matching by state.

Usage example

Let me remind you of the condition from chapter 17.3 "Implementing an Object-Oriented Design Pattern" of the rust-book. The following behavior is required:

pub fn main() {
    let mut post = blog::Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());
    post.request_review(); // without request_review() - approve() should not work
    post.approve();  
    assert_eq!("I ate a salad for lunch today", post.content());
}

with macro #[gen()] this is solved like this:

mod blog {
    enum State {
        Draft,
        PendingReview,
        Published,
    }

    pub struct Post {
        state: State,
        content: String,
    }

    #[methods_enum::gen(Meth, run_methods)]
    impl Post {
        pub fn add_text(&mut self, text: &str);
        pub fn request_review(&mut self);
        pub fn approve(&mut self);
        pub fn content(&mut self) -> &str;

        #[rustfmt::skip]
        fn run_methods(&mut self, method: Meth) -> &str {
            match self.state {
                State::Draft => match method {
                    Meth::add_text(text) => { self.content.push_str(text); "" }
                    Meth::request_review() => { self.state = State::PendingReview; "" }
                    _ => "",
                },
                State::PendingReview => match method {
                    Meth::approve() => { self.state = State::Published; "" }
                    _ => "",
                },
                State::Published => match method {
                    Meth::content() => &self.content,
                    _ => "",
                },
            }
        }

        pub fn new() -> Post {
            Post { state: State::Draft, content: String::new() }
        }
    }
}

In the handler method (in this case, run_methods), simply write for each state which methods should work and how.

The macro duplicates the output for the compiler in the doc-comments. Therefore, in the IDE1, you can always see the declaration of the generated enum and the generated method bodies, in the popup hint above the enum name:

enum popup hint

enum popup: bodies

Syntax for calling a macro

For at most one return type from methods

#[methods_enum::gen(EnumName , handler_name]`

where:

  • EnumName: The name of the automatically generated enum.
  • handler_name: Handler method name

For more than one return type from methods

#[methods_enum::gen(EnumName , handler_name , OutName]

where:

  • OutName: The name of an automatically generated enum with variants from the return types.

Links


The gen() macro loses out to impl_match! in terms of restrictions and ease of working with methods and their output values. The benefit of gen() is that it allows you to see the full match-expression and handle more complex logic, including those with non-trivial incoming expressions, match guards, and nested matches from substate enums.


License

MIT or Apache-2.0 license of your choice.


Footnotes

  1. rust-analyzer may not expand proc-macro when running under nightly or old rust edition. In this case it is recommended to set in its settings: "rust-analyzer.server.extraEnv": { "RUSTUP_TOOLCHAIN": "stable" } 2 3

Commit count: 95

cargo fmt