RPN calculator in Rust

Posted on Jun 6, 2022

Since I’m now a Rust developer by day, I’m trying to flatten my learning curve by using Rust for pet/learning projects. Trying to pick a task small enough to learn and looking for a calculator that is usable directly from the command-line, I didn’t find anything I liked that was really simple. I’m aware of calculators like bc(1), dc(1), kalker and of course calc in Emacs. All of these are extraordinary in their own right. For any extensive calculations and/or statistics there is of course Julia and R. What I wanted was something simple that allows one to type simple expressions directly in the shell without the need for quoting or escaping of special characters. Hence rpn.

The start

Because Rust has support for ADT’s via enums see the Enums and Pattern Matching section in the Rust book for an introduction, handling a combination of integer and floating point arithmetic can be implemented with little effort. The first type written down looks like this:

1
2
3
4
enum Num {
    Integer(i64),
    Float(f64, usize),
}

The second value associated with the Float variant, is used to track the number of signicant figures. The possibility of an item being either a number or an operator is represented by the Item enum:

1
2
3
4
enum Item {
    Operand(Num),
    Operator(Oper),
}

Implementing the arithmetic was simply forwarding the hard work to the appropriate Rust standard library function. With a few macro’s to implement the necessary traits progress was nice. As an example here is the implementation of the binary operator trait:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
trait BinaryOperator: Display {
    fn apply(&self, lhs: Num, rhs: Num) -> Num;
}

macro_rules! impl_bin_op {
    ($oper:ident, $native_op:path) => {
        impl BinaryOperator for $oper {
            fn apply(&self, lhs: Num, rhs: Num) -> Num {
                match (lhs, rhs) {
                    (Num::Integer(l), Num::Integer(r)) => Num::Integer($native_op(l, r)),
                    (Num::Float(l, prec), Num::Integer(r)) => {
                        Num::Float($native_op(l, r as f64), prec)
                    }
                    (Num::Integer(l), Num::Float(r, prec)) => {
                        Num::Float($native_op(l as f64, r), prec)
                    }
                    (Num::Float(l, lprec), Num::Float(r, rprec)) => {
                        Num::Float($native_op(l, r), min(lprec, rprec))
                    }
                }
            }
        }
    };
}

struct Add;
impl_bin_op!(Add, std::ops::Add::add);

Because the standard arithmetic operations are implemented is functions in the standard library these can be used here as the $native_op function.

The middle

The parsing of the expressions, if one could call it that because all that is really happening is tokenizing. Is split into three FromStr implementations. The first one of the Item enum:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
impl FromStr for Item {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        s.parse::<Num>().map_or_else(
            |err_1| s.parse::<Oper>().map_or_else(
            |err_2| Err(format!("could not parse '{s}' as either a number or operator; {err_1}; {err_2}")),
            |o| Ok(Self::Operator(o))),
            |n| Ok(Self::Operand(n)))
    }
}

The second one is the implementation for Num:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
impl FromStr for Num {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        if PI_TOKEN == s {
            return Ok(PI);
        }
        s.parse::<i64>().map_or_else(
                |err_1| {
            let prec = s
                .chars()
                .take_while(|c| *c != 'e' || *c != 'E')
                .filter(|c| char::is_digit(*c, 10))
                .count();
            s.parse::<f64>().map_or_else(
                |err_2| {
                    Err(format!(
                        "could not parse as floating point or integer number '{s}'; {err_1}; {err_2}"
                    ))
                },
                |f| Ok(Self::Float(f, prec)),
            )
                },
                |i| Ok(Self::Integer(i)),
            )
    }
}

Here we see the nice high-level method chains that can be written in Rust, in this case to calculate the number of significant figures eluded to earlier.

The FromStr implementation, is a big switch statement looking something like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

impl FromStr for Oper {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "+" => Ok(Self::Bin(BinOp {
                oper: Box::new(Add),
            })),
            "-" => Ok(Self::Bin(BinOp {
                oper: Box::new(Sub),
            })),
            "x" => Ok(Self::Bin(BinOp {
                oper: Box::new(Mult),
            })),
            })),
            "..=" => Ok(Self::Range(RangeOp {
                oper: Box::new(RangeInc),
            })),
            bad => Err(format!("not a valid operator '{bad}'")),
        }
    }
}

The main function takes in all arguments splits them on whitespace and call’s .parse::<Item>().

The evaluator takes the Item’s and performs the operations by manipulating the stack as one would expect. Using the mentioned macro’s this calculator was implemented in 480 lines of code, excluding the tests.

Conclusion

It was fun to work on this tiny side-project, it learned me a great deal especially about traits and the differences between iter() and into_iter().