Real life example use case of macros in Rust: Declarative macros

Real life example use case of macros in Rust: Declarative macros

What are macros?

Macros in rust are the way of writing the code of requirement, with the code itself. That's confusing, right? Basically talking, it gives us the power of generating the context suitable code whenever and wherever we would need it in the program. "How different are they from the functions then?", this question suddenly arises, I know! So, the code you will be generating with the help of macros changes according to the context. Thus, giving us the power of meta-programming. The main aim of this blog is not to explain what macros are, but to demonstrate the use of macros in some relevant real life example. If you don't already know what macros are I would recommend you to read about it from The book. I don't think you will need any other resource to understand macros better, than this book.

If you read the book or if you already know, there are two types of macros in rust. Declarative macros and Procedural macros. In this blog we are going to have a look at an implementation of Declarative macro.

Use case example

Suppose we have declared a struct named ServerConfig. This struct can be used for encapsulating the configuration details of the server under one type. Now, server configurations are usually done in web-frameworks to set up the server with some basic properties which we call configurations. Listing down the configurations of the server, which we will be using in this example are:

  • Host: Carries the details like IP address of the server or Domain name of the server.

  • Port: Specifies the port of the server in use, which will listen and serve the coming requests.

  • Use Https: This can be a boolean value specifying whether this server uses https or just http, true means https and false says that the server uses http.

  • Timeout: Timeout duration of the server for handling the requests. Determines for how long a server should wait for the client to send data to the server.

  • Max connections: Determines the maximum number of concurrent connections a server can handle, useful for controlling resource usage at a time.

So, these are some essential configurations, generally a server would need to be set up with, to be able to serve the requests properly.

Thus, our ServerConfig struct should look something like this.

#[derive(Debug)]
struct ServerConfig {
    host: String,
    port: u16,
    use_https: bool,
    timeout: Option<u64>,
    max_connections: Option<u32>,
}

Off course, If you don't know, you might be wondering what's that #[derive(Debug)] written above there, and what does it mean. To be straight, it is called an attribute in Rust. This one here, particularly implements a Debug trait on our struct, which basically makes it possible to print this struct in human readable form. For this, we need to use {:?} syntax. If you know C++ or Java, you cannot directly print complex data-types like vectors and maps with a single print statement, right? Here in Rust, you can, because of this above mentioned attribute. Attributes in Rust, I personally think, could be compared to annotations in Java or Kotlin or if they are, in any other languages.

Let's come back to the macros.

What are we using a macro for?

If you read with patience, or even if you don't, I would like to talk about OOP classes here. If we would be writing this in any typical Object Oriented programming language, we would create a class of name ServerConfig with all its properties mentioned in it, and then use constructors to initialize it. And we know, we can have construction overloading to have control over the initialization. What I mean by this is, suppose we want to give this power to the user that, they can initialize all the properties in the struct if they want, or otherwise they choose to have default values to some properties of struct, initializing only those which need explicit initialization. We can do this using different constructors with different arguments, right? But here, Rust isn't a typical Object Oriented language, although it pretty much provides all the functionalities an OOP language does.

Here in Rust, to do the same we will be using a macro, a declarative macro to be precise. This macro will enable us to have default values to the properties of ServerConfig which could be mutated with user defined values if given explicitly.

Implementing a Declarative macro

macro_rules! server_config {
    ( $( $key:ident : $value:expr ),* $(,)? ) => {
        {
            let mut config = ServerConfig {
                host: String::from("localhost"),
                port: 80,
                use_https: false,
                timeout: None,
                max_connections: Some(50000),
            };

            $(
                config.$key = $value;
            )*

            config
        }
    };
}

The syntax is very counter-intuitive, I know. Let me break down it for you.

  • macro_rules!: This is just a syntactical keyword, used to declare that following is the definition of a declarative macro. Simply, just written before the definition of the declarative macro.

  • server_config: This is a name of the macro. Note that, we don't write an exclamation mark after the name in the definition of a macro, but we do mention it after the macro's name, while using it (calling it). Then we open the first curly brace to write the macro body.

  • $($key: ident : $value: expr),*$(,)?: This expression within the parenthesis, before the body, defines the argument of the macro in the form of a pattern, that will be used in this macro. Further, $ distinguishes the macro variables from regular Rust variables, when written just before them, without a white-space. Thus, $key, $value and also $($key: ident : $value: expr) are variables in this macro. ident and expr are types of the variables. ident means the identifier type and expr is expression type, specifying that, the $key is an identifier in the pattern and $value can be any expression like a string, a vector, or even another struct type.

    $($key: ident : $value: expr),* this whole expression defines the pattern of a key-value pair which can be separated by a comma ,, and the * sign here means this pattern can repeat itself for zero or more times. Yeah, if you have studied Theory of Computing this might be familiar to you.

    The next $(,)? can be used to control the allowance of a trailing comma in the input. Here, it allows the trailing comma, i.e. you can have a comma after the last key-pair, while initializing your struct with this macro. The question mark sign ? after $(,) means the expression can repeat for zero or one times. You will get a clear idea of this, when we use this macro to initialize the ServerConfig struct.

  • Then we initialize the new variable config of type ServerConfig with the struct literal ServerConfig {}. Here, we specify the default values to the configurations while initializing, some or all of which could be mutated (overridden) further by the user using our macro. Then, we use the following syntax to mutate the values of the configurations with the arguments of the macro.

                  $(
                      config.$key = $value;
                  )*
    

    This overrides the values of pre-initialized config variable matching the corresponding keys from the arguments of the macro. Again the * here means there can be zero key-value pair coming as an argument or more than zero.

  • config: Finally, returning config. (In Rust you can return a variable from functions, macros by just writing the variable name without a semicolon).

Thus, to summarize this. 1). We first specify the pattern with which to accept the arguments in the macro. 2). We initialized the default variable of ServerConfig type with its default values. 3). We override the values of this variable with the arguments of the macro, if passed any, matching the corresponding keys. 4). Then simply, we return the initialized variable.

So, this is an example implementation of the declarative macro. It is called as a declarative macro because here we use the macro_rules! system to define patterns and define how those patterns should be expanded into the Rust code. We simply declare a pattern and the way to change this pattern as an output.

Using this implemented macro

fn start_server(config: ServerConfig) {
    // Server initialization code
    println!("Starting server at {}:{}", config.host, config.port);
    if config.use_https {
        println!("Using HTTPS");
    }
    if let Some(timeout) = config.timeout {
        println!("Timeout: {} ms", timeout);
    }
    if let Some(max_connections) = config.max_connections {
        println!("Max Connections: {}", max_connections);
    }
}

fn main() {
    let config = server_config!(
        host: String::from("example.com"),
        port: 8080,
        use_https: true,
        timeout: Some(3000),
    );

    start_server(config);
}

Using a macro is very simple, as mentioned before, we call a macro by its name followed by an exclamation ! mark, without any white-space in between. Here, we use server_config macro to initialize the variable config with type ServerConfig and default values as passed in the macro as arguments. Here, you can see there is a trailing comma after timeout: Some(3000) key-value pair in the arguments list, we have discussed this before, that it is possible because we used $(,)? this pattern while defining the macro. If we had not used that pattern, using comma after the last key-value pair would have given a compilation error.

If you notice here, we haven't passed max_connections configuration in the macro as an argument so, the value of max_connections key in the config, will be the default value that we gave to the corresponding key in the config variable while defining the macro.

The start_server function can potentially use this server with configurations encapsulated in config for request processing and serving. Here, it is just printing the values of different server configurations.

Also to remind you of #[derive(Debug)] attribute, because of this attribute, we can also print the config using another declarative macro println!. So, we can do something like...

fn main() {
    let config = server_config!(
        host: String::from("example.com"),
        port: 8080,
        use_https: true,
        timeout: Some(3000),
    );

    println!("{:?}", config);
}

And, this will print the configstruct in the human readable form.


Alternatively, we can also define the functions within the impl block to initialize a struct, in the following way...

struct ServerConfig {
    host: String,
    port: u16,
    use_https: bool,
    timeout: Option<u64>,
    max_connections: Option<u32>,
}

impl ServerConfig {
    // Constructor-like function
    fn new(host: String, port: u16, use_https: bool, timeout: Option<u64>, max_connections: Option<u32>) -> Self {
        Self {
            host,
            port,
            use_https,
            timeout,
            max_connections,
        }
    }

    fn new_with_defaults(host: String, port: u16) -> Self {
        Self {
            host,
            port,
            use_https: false,
            timeout: Some(3000),
            max_connections: Some(100),
        }
    }
}

fn main() {
    let config = ServerConfig::new(
        String::from("localhost"),
        80,
        false,
        None,
        None,
    );

    println!("{:?}", config);
}

But, this does not provide the kind of flexibility that macro provides. Here, we will have to define different functions, which are also called as associated functions, handling the different cases of which configuration has a default value, and which configuration doesn't. This is pretty much what I wanted to share about declarative macros.

Conclusion

Macros are powerful tool that Rust provides the programmers with. Even though it has a pretty complicated syntax, understanding it would make someone a great Rust programmer with an ability to use a great feature of meta-programming. I hope it was a fun read. If you find any error in the blog even typographical, please inform me. Hashnode enables the authors to edit their blogs so, it can be edited. Thank You for reading! See you again in another blog.