What's the most idiomatic way to define a set of numeric constants?

Greetings, community! I've been learning Rust for the last couple of months, and trying figure out the "Rustacean Way".

Let's say I'm building a command-line client. I want to return different error codes to the shell, depending on the error condition. Since magic numbers are usually a code smell, I want to define some constants to represent the different codes. I'm currently aware of at least 3 different ways to do this.

Global constants...

const ARG_PARSE_ERR: i32 = 1;
const FILE_ERR: i32 = 2;
// ...
process::exit(ARG_PARSE_ERR);

Associated constants...

struct CmdErr;

impl CmdErr {
    const ARG_PARSING: i32 = 1;
    const FILE: i32 = 2;
}
// ...
process::exit(CmdErr::ARG_PARSING);

C-style enums...

enum CmdErr {
    ArgParsing = 1,
    File = 2,
}
// ...
process::exit(CmdErr::ArgParsing as i32);

As a grizzled old Java vet, my instinct is to use the associated constants or maybe the enum. But what is the preferred way to do this sort of thing in Rust, and why?

Thanks,
Eric

As for just numeric constants I would say, enums.

As for Error Termination Codes buckle up.

TLDR: The enum would, in my opinion be the more rustic, way of doing it. And would be adequate for your use case.

Errors are somewhat verbose in rust, and while the principle isn't changing, a lot of language level features are still being worked on. Now generally when you create an Error type, you would want to implement the Error trait, but it can be a lot. A raw rustic way to do it would be like the following (this won't work if you try to compile it):

// application error type
enum CmdErr {
    ArgParsing(String), // Store underlying information
    File(std::io::Error), // Or the source error
}

// Display is required to implement Error
impl Display for CmdErr {
   fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
       match self {
           CmdErr::ArgParsing(arg) => write!(f, "Failed to parse arg: {}", arg),
           CmdErr::File(source) => write!(f, "Failed to process file: {}", source),
       }
    }
}

// The Error trait is used to specify errors
impl Error for CmdErr {}

// Your Error could have an underlying error, should probably
// implement the From trait for convience
impl From<std::io::Error> for CmdErr {
   fn from(error: std::io::Error) -> Self {
        CmdErr::File(error)
    }
}

// The nightly termination trait returns the exit code
// main will use on err exit
impl Termination for CmdError {
    fn report(self) -> i32 {
       match self {
           CmdErr::ArgParsing(arg) => 0,
           CmdErr::File(source) => 1,
       }
    }
}

// Main can return a Result
fn main() -> Result<(), CmdErr> {
    run().or_else(|err| log_error!(err))
}

Note: this won't compile on stable rust because the Termination trait is currently only in nightly builds.

Now as you can see, this is just absolutely awful. But most people tend to use a crate such as thiserror to generate the boilerplate for them through macros. The above would essential become

use thiserror::Error;

#[derive(Debug, Error)]
enum CmdErr {
    #[error("Failed to parse the argument {0}")] 
    ArgParsing(String),
    #[error("Failed to process file: {0}")]
    File(#[from] std::io::Error),
}

Now this still leaves the termination values, you could impl the enum to return them

impl CmdErr {
    fn exit_code(self) -> i32 {
        match self {
            CmdErr::ArgParsing(_) => 1,
            CmdErr::File(_) => 2,
        }
    }
}

Then in main if a function returned a CmdError you could call the method.

println!("{}", cmd_err); // tell the user what errored
std::process::exit(cmd_err.exit_code()) // exit

Now for your case, do you need to go through and derive the Error trait, impl methods to get the Error codes, propagate all the Errors upto main for handling, impl From for all underlying Errors? Probably not, if you were writing a crate to be used as a library, you may want to have a nice Error type so users could get the detailed error information in a packaged and usable form. But if you just want exit codes and outputing the error messages at the failure site, then C style enum is fine for that.


One day you may be able to derive Termination

#[derive(Debug, Error, Termination)]
enum CmdErr {
    #[error("Failed to parse the argument {0}")]
    ArgParsing(String),
    #[error("Failed to process file: {0}")]
    File(#[from] std::io::Error),
}

But we aren't quite there yet :slight_smile:

1 Like

Wow! Thanks for the detailed explanation. That solution is a lot more encapsulated than I was expecting :smiley:

So for a very simple or throwaway program, if I start with an enum, it will be a bit easier to grow into the more self-contained solution, because I'll just be attaching behavior to the enum & variants that are already in place. Is that a good summary?

Also, not to get off on a tangent, but how should I treat that Termination trait? Scanning through the RFC (which is so very far over my head), it looks like the supporting compiler & runtime features are stable, but the trait itself is still experimental. Is there a timeline for it to become stable?

Yeah thats a good way of putting it.

If you are using the nightly compiler you can go ahead and use it now. I'm guessing the trait either has a few things to be worked on, or it is just lower priority than other features being worked on. It's hard to tell at a glance. Looking at some of the later comments, they may be waiting on specialization. If that's the case don't hold your breath for the trait.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.