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.
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.
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.
-
You can also include
impl (Trait) for ...
blocks in a macro. The name of theTrait
(without the path) is specified in the enum before the corresponding arm-block. Example withDisplay
- 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 theenum
declaration, in the example:@enum Shape {...
disables passing to theenum
compiler: only match-arms will be processed. This may be required if thisenum
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
orsn
in any case - replaces the semantic binding of the names of methods and traits inenum
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.
- flag
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.
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:
#[methods_enum::gen(
EnumName ,
handler_name]`
where:
- EnumName: The name of the automatically generated enum.
- handler_name: Handler method name
#[methods_enum::gen(
EnumName ,
handler_name ,
OutName]
where:
- OutName: The name of an automatically generated enum with variants from the return types.
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.
MIT or Apache-2.0 license of your choice.
Footnotes
-
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