Generics, traits or macros?

Sorry for the vague title, trying to find an efficient way of doing something.
Optimization phase of a compiler, in the constant folding pass. I have a bunch of operations on a bunch of values. If both operands (for a binary op) are constants then the operation can be replaced a constant (int x = 4 + 5 for example). I can obviously make a huge set of multiply nested matches but it seems like I could simplify it

the values are

pub enum Value {
    Int32(i32),
    Int64(i64),
    UInt32(u32),
    UInt64(u64),
    Double(f64),
    Char(i8),
    UChar(u8),
    String(String),
    Variable(String, SymbolType),
    Void,
}

the first 7 are all constants of the given type. The binary operators are (I also have Unary ops)


pub enum BinaryOperator {
    Add,
    Subtract,
    Multiply,
    Divide,
    Remainder,
    BitAnd,
    BitOr,
    BitXor,
    ShiftLeft,
    ShiftRight,

    Equal,
    NotEqual,
    LessThan,
    LessThanOrEqual,
    GreaterThan,
    GreaterThanOrEqual,
}

so for example if the operator is Add and the Values are int32 I need to do (using EnumAsInner for the as_xxx calls)

let new_constant = Value::Int32(left.as_int32().unwrap().wrapping_add right.as_int32().unwrap()));

(Note the wrapping add)

I wrote this macro

macro_rules! binop_num {
    ($left:ident,$right:ident, $op:path) => {
        match $left.stype() {
            SymbolType::Int32 => {
                Value::Int32($op($left.as_int32().unwrap(), $right.as_int32().unwrap()))
            }
            SymbolType::UInt32 => Value::UInt32($op(
                $left.as_u_int32().unwrap(),
                $right.as_u_int32().unwrap(),
            )),
            SymbolType::Int64 => {
                Value::Int64($op($left.as_int64().unwrap(), $right.as_int64().unwrap()))
            }
            SymbolType::UInt64 => Value::UInt64($op(
                $left.as_u_int64().unwrap(),
                $right.as_u_int64().unwrap(),
            )),
            // SymbolType::Double => {
            //     Value::Double($op($left.as_double().unwrap(), $right.as_double().unwrap()))
            // }
            SymbolType::Char | SymbolType::SChar => {
                Value::Char($op($left.as_char().unwrap(), $right.as_char().unwrap()))
            }
            SymbolType::UChar => {
                Value::UChar($op($left.as_u_char().unwrap(), $right.as_u_char().unwrap()))
            }
            _ => panic!("Unsupported type for binop: {:?}", $left.stype()),
        }
    };
}

used like this

                            let result = match op {
                                BinaryOperator::Add => {
                                    binop_num!(left, right, WrappingAdd::wrapping_add)
                                }
                                BinaryOperator::Subtract => {
                                    binop_num!(left, right, WrappingSub::wrapping_sub)
                                }
                                ....

the problems are

  • doesnt compile for double, because double doesnt support wrapping add or bitwise ops
  • the logic ones are different - they need to return boolean or 0/1
  • the shift operations right hand are always int32 (for other operations left and right are the same type)

Any suggestions about the best way to do this, like I said I can simply type out enormous matches for each combination of Instruction (BinaryOperator, UnaryOperator,..), sub op (BinaryOperator::Add, Unary::Negate,..) and value type

Generally you want to break up the operations into groups with a consistent pattern, eg arithmetic, bitwise, and comparison operations, then you can share the logic to raise the operands before applying the operation.

Here's my stab at it, probably a lot more room to simplify it though: https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=d3806e519c6a9acbacec6f793d3feac5 (obviously you will want to use your own desired semantics)

ok, thanks, that basically what I did (I have binop_num, binop_shift etc)

It gets more and more complicated tho, I needed wrapping_add not regular add and f6 doesnt support that. It keeps getting more complex.

I wondered if there was a totally different way of doing it (maybe using traits or generis, or slicing it differently - say binop_f64, binop_u64,...)

I was wondering about passing in a lambda instead of a function

Ty

Yeah, there's no reason you couldn't add more machinery to simplify this using traits or the like!

The ideas are more that:

  • if you do have enums you do eventually need to drop down to a match somewhere,
  • that can then do whatever, including dispatch to a trait,
  • but if you're doing that you might as well handle the major differences at the macro level.
  • but regardless you do want to separate matching the operands and the op.

There's a few other tricks you might want to look into, depending on your semantics. One is a type like:

enum ArithOperands {
  ...,
  U32(u32, u32),
  ...,
  F32(f32, f32),
}

impl ArithOperands {
  fn try_from(l: Value, r: Value) -> Result<Self>;
}

That can parse out arguments separately from applying them, and validates them to only be ones valid for arithmetic, that reduces the need for macros slightly, but you still need a match per operand type and operation somewhere to run it!

You might also be able to use num-traits, but most of the traits there are not dyn-compatible and don't try to paper over int/float differences like with wrapping_add so they can only reduce busy work a little.