Custom types identical to basic data types

Hi all. I apologize for the somewhat unclear title, I did my best :slight_smile:

Please look at this example:

pub struct Degrees(f64);
pub struct Radians(f64);

pub fn do_something_with_degrees(num: Degrees) -> Degrees {
    Degrees(num.0 * num.0 + num.0 / num.0)
}

pub fn do_something_with_radians(num: Radians) -> Radians {
    Radians(num.0 * num.0 + num.0 / num.0)
}

pub fn do_something(num: f64) -> f64 {
    num * num + num / num
}

pub fn main() {

    do_something_with_degrees(Degrees(666.0));
    do_something_with_radians(Radians(666.0));
    do_something(666.0);

}

As you may guess, what I'm trying to do here is to define specific "versions" of f64 in order to prevent an angle expressed in degrees to be passed to a function that expects radians, and vice versa. But, under the hood, all the three do_something* functions are identical (at least... I think).

I'm not an expert, but I was expecting the compiler to produce the same code for these functions, or more generally to see my single value structs Radians and Degrees to be treated exactly like f64. Accordingly to Compiler Explorer, this is not the case: Compiler Explorer . do_something_with_radians and do_something_with_degrees are identical but they differ from do_something, both in the inner code and in the call site; there's a little more machine code.

For code that's not performance critical I wouldn't care, but in case of a measurable performance hit I would stay with simple f64s exposing myself to some potential bugs due to mixing of radians and degrees (in this example).

So... There's something I could do better here?

Less important question: there's a way to avoid the .0 to access the struct's inner value and do calculations on it?

Thanks for any hint!

Why have you expected that? These are different function, they deal with different types, one needs to pack/unpack things, one doesn't need to do that.

If course if you request compiler to optimize your code they would be identical.

Yes. It's not a good idea to run performance-critical code with optimizations disabled.

1 Like

To request optimization when using Compiler Explorer, add -C opt-level=3 to the compiler flags. In that case, all 3 of your functions will, if you convince the compiler not to inline or delete them entirely, compile into the same symbol (because identical machine code is deduplicated). Demo:

use std::hint::black_box;

pub struct Degrees(f64);
pub struct Radians(f64);

#[inline(never)]
pub fn do_something_with_degrees(num: Degrees) -> Degrees {
    Degrees(num.0 * num.0 + num.0 / num.0)
}

#[inline(never)]
pub fn do_something_with_radians(num: Radians) -> Radians {
    Radians(num.0 * num.0 + num.0 / num.0)
}

#[inline(never)]
pub fn do_something(num: f64) -> f64 {
    num * num + num / num
}

pub fn main() {
    black_box(do_something_with_degrees(Degrees(666.0)));
    black_box(do_something_with_radians(Radians(666.0)));
    black_box(do_something(666.0));
}

With these annotations, and opt-level 3, the assembly output is:

example::do_something:
        movapd  xmm1, xmm0
        mulsd   xmm1, xmm0
        divsd   xmm0, xmm0
        addsd   xmm0, xmm1
        ret

.LCPI1_0:
        .quad   0x4084d00000000000
example::main:
        push    r14
        push    rbx
        push    rax
        mov     rbx, qword ptr [rip + example::do_something@GOTPCREL]
        movsd   xmm0, qword ptr [rip + .LCPI1_0]
        call    rbx
        movsd   qword ptr [rsp], xmm0
        mov     r14, rsp
        movsd   xmm0, qword ptr [rip + .LCPI1_0]
        call    rbx
        movsd   qword ptr [rsp], xmm0
        movsd   xmm0, qword ptr [rip + .LCPI1_0]
        call    rbx
        movsd   qword ptr [rsp], xmm0
        add     rsp, 8
        pop     rbx
        pop     r14
        ret

All three functions are turned into one symbol, and main() calls it 3 times.

2 Likes

You can pattern match in the parameter:

pub fn do_something_with_degrees(Degrees(num): Degrees) -> Degrees {
    Degrees(num * num + num / num)
}

Or afterward:

pub fn do_something_with_degrees(deg: Degrees) -> Degrees {
    let Degrees(deg) = num;
    Degrees(num * num + num / num)
}

Or you can implement the std::ops traits so that your new type supports arithmetic operators. (When doing this, you should think about which ones make sense; for example, multiplying Degrees by Degrees rarely makes sense unless it's part of some larger formula, so you might choose to omit such an implementation.)

You should also #[derive(Clone, Copy)] whether or not you do that, to make it convenient to use.

1 Like

So the compiler is actually smart as I expected. And... likely smarter than me :slight_smile:

Thanks, I learned something!

That's the way to go, if you actually want to avoid errors such as adding Degrees to Radians. The .0 should be private to the module/crate implementing the operations, and only the operations should be public.

Multiplying Degrees by Degrees may make sense, but then that doesn't return Degrees, it returns DegreesSquared!

Radians are a weird case because really, radians are unit-less. 1 Radian = 1 meter / 1 meter = 1.

Whether a quantity has units (or more precisely, dimensions โ€” "meter" is a unit and "length" is a dimension) is in part a matter of practicality. Just because the definition of the radian arises from a computation where all the units cancel out does not mean we can't choose to add dimensions to help us avoid making mistakes. The only cost is that some functions need conversions where they wouldn't otherwise. If you're performing calculus, yes, certainly you don't want the input of sin() to be โ€œin radiansโ€ rather than dimensionless. But most programs are not doing that, and even the ones that do numeric integration may still benefit from additional dimensions/newtypes in their function signatures.

Sure, yeah, no problem with creating a radian unit and converting when necessary. Radians convert to unit-less with a factor of 1 radian = 1, whereas degrees can also be converted to unit-less with a factor of 1 degree = ฯ€ / 180.