How to write a method which works with any type int?

Recently I needed a method converting time in 24 hours format in the twelve hours format. AI gave me a perfect solution as:

pub fn convert_24_to_12(hour_24: u32) -> Result<(u32, bool), Box<dyn Error>> {
    // Validate ranges
    if hour_24 > 23 {
        return Err("Hour must be 0-23".into());
    }

    // Convert to 12-hour format
    let hour_12 = match hour_24 % 12 {
        0 => 12,
        h => h,
    };

    Ok((hour_12, hour_24 > 11))
}

However when I call the method with hours as u8, Rust asks me to use .into(). Fine, but I can use any integer in C++. Is there something similar in Rust? AI gives me some solution as using crates.io. It's good, but since I am learning Rust, I want to know what's a mechanism behind the scene.

Details depend on what you want your signature to be, but ultimately if you want to pass in actually different types it will come down to figuring out the trait bounds needed to do your operation (be it traits from a library like num, traits from std, or your own traits).

TryInto<u8> might be adequate for the example given. Or you could have something like T: TryFrom<u8> + PartialOrd + Rem<Output = T>.

Without knowing your use case, I still think that your AI solution is far from "perfect".

  • Why does the function not already get passed a type that represents 24 hours with respective invariants?
  • Why do you return a tuple (u32, bool) instead of an enum TwelveHours { Am(u8), Pm(u8), } or even with a type with even stronger invariants than u8?

C and C++ has implicit integer promotion, rust does not.

in rust, for widening integer conversion, Into is implemented because they are infallible. for narrowing conversions, TryInto should be used and overflow/underflow should be handled correctly.

primitive cast exists but it silently truncates for lossy conversions, so use with caution.

third party crates like num-traits provide generic way to work with integers and floating points, and conversions between them.

if you want to keep your implementation of u32 but to hide the .into() on callsites, use a generic wrapper function, which can call the concrete implementation internally, like this:

pub fn convert_24_to_12(hour_24: impl Into<u32>) -> Result<(u32, bool), Box<dyn Error>> {
    convert_24_to_12_u32(hour_24.into())
}
// your original implementation but renamed
// `pub` or not, it's your choice
pub convert_24_to_12_u32(hour_24: u32) -> Result<(u32, bool), Box<dyn Error>> {
    //...
}

if you want to write an "overloaded" function for any integer type, it is a bit verbose. you need a trait to abstract away the operations in the function, this includes:

  • a method for the hour_24 > 23 and hour_24 > 11 comparisons, e.g. std::cmp::PartialOrd::gt()
  • a method for the hour_24 % 12 remainder, e.g. std::ops::Rem::rem()
  • a method for the remainder type (if different from the input) to check for value 0
  • a method to construct a literal value 12

then you implement the trait for all the integer types you want to support.

this is very tedious so it's better to use a third party crate, but you don't use cargo, so you'll have to do it yourself.

alternatively, you can simply overload the entire convert_24_to_12() function, slightly better than abstract away the individual operations.

also, you can create the impl blocks using a macro to reduce duplicated code.

Because you will need to create another function which converts an arbitrary user input to the type.

It looks like a good solution, but it fails to compile test cases as:

error[E0277]: the trait bound `u32: From<i32>` is not satisfied
   --> /home/dodik/projects/simtime/test.rs:17:42
    |
 17 |     assert_eq!(simtime::convert_24_to_12(0).unwrap().0, 12);
    |                ------------------------- ^ the trait `From<i32>` is not implemented for `u32`
    |                |
    |                required by a bound introduced by this call
    |
    = help: `u32` implements trait `From<T>`:
              From<Ipv4Addr>
              From<bool>

              From<char>
              From<std::ascii::Char>
              From<u16>
              From<u8>
    = note: required for `i32` to implement `Into<u32>`
note: required by a bound in `convert_24_to_12`
   --> /home/dodik/projects/simtime/./lib.rs:269:39
    |
269 | ...: impl Into<u32>) -> Result<(u32, bool), Box<dyn Error>> {
    |           ^^^^^^^^^ required by this bound in `convert_24_to_12`


But if I add 0u8, it works fine. It looks like From<u32> to u32 is considered as nonsense.

That TwentyFourHours type may just implement FromStr for parsing and the TwelveHours type may just implement From<TwentyFourHours> (and vice versa).

Actually AI thinks like that, because it gave me the function working with &str, and I simply removed the conversion. It looks like I need to start thinking more like AI. A result of the function was also a string. I understand that makes the function acts more like a typescript function.

this is indeed a quirk of numeric literal type inference in generic context.

in short, a literal integer has some special "literal" type, distinct from all integer types, it is then casted to the inferred target type, and an error is reported if the cast would result an overflow. that's how the lint overflowing_literals works.

if type inference didn't result a single concrete type, i32 is used as a fallback.

in the your original code, because the argument type is u32, the literal is inferred to have type u32 correctly, no fallback happens. however, when the argument is generic, convert_24_to_12(0), the literal 0 is inferred to have (fallback) type i32 due to this quirk.

to avoid the fallback, a type suffix is needed. very annoying, but it is whatt it is.

I was bitten by this many times. one example is with the fugit crate's ExtU32, I want to write 100.millis(), but I have to write 100u32.millis(). if I forgot, bang! compile error!

I believe that that's a fallacy. LLMs are trained on data e.g. from software engineers.
You should not start thinking more like an "AI", but learn some software engineering principles, which the LLMs are trained on and thus are likely to generate answers on that basis if configured and prompted correctly.

Thank you for pointing that. Indeed, saying thinking as AI just means thinking as majority of software engineers. Since I am trying to stay apart of the crowd, sometimes I can mistakenly think that AI may think.

Thanks for the explanation. Many times I just puzzled what type of a numeric literal Rust is using.

Maybe you can use traits and macro.

First, create such a trait:

trait TimeConvert
where
    Self: Sized,
{
    fn convert_24_to_12(self) -> Result<(Self, bool), Box<dyn Error>>;
}

And then, write a macro that implements this trait:

macro_rules! impl_time_convert {
    ($($t:ty),*) => {
        $(
            impl TimeConvert for $t {
                fn convert_24_to_12(self) -> Result<(Self, bool), Box<dyn Error>> {
                    if self > 23 {
                        return Err("Hour must be 0-23".into());
                    }

                    // Convert to 12-hour format
                    let hour_12 = match self % 12 {
                        0 => 12,
                        h => h,
                    };

                    Ok((hour_12, self > 11))
                }
            }
        )*
    };
}

Finally, use this macro to implement the TimeConvert trait for all integer types:

impl_time_convert!(
    u8, u16, u32, u64, u128, usize, i8, i16, i32, i64, i128, isize
);

The convert_24_to_12 method is now available for all integer types:

10_u8.convert_24_to_12().unwrap();
11_u16.convert_24_to_12().unwrap();
12_u32.convert_24_to_12().unwrap();
13_u64.convert_24_to_12().unwrap();
14_u128.convert_24_to_12().unwrap();
15_usize.convert_24_to_12().unwrap();
16_i8.convert_24_to_12().unwrap();
17_i16.convert_24_to_12().unwrap();
18_i32.convert_24_to_12().unwrap();
19_i64.convert_24_to_12().unwrap();
20_i128.convert_24_to_12().unwrap();
21_isize.convert_24_to_12().unwrap();

that's broken for negative inputs since you forgot to adjust the test that assumes the input is unsigned so doesn't need to test for < 0

It seems another good possibility. Actually generics in C++ are based on using a macro processor, which is an equivalent to macros in Rust in some view. Anyway, I decided to do nothing right now because I use the function in a relatively small amount of projects. I also found some version of the function I did 3 years ago myself:

pub fn convert_24_to_12(hour_24: u32) -> Result<(u32, bool), Box<dyn Error>> {
    match hour_24 {
        0 => Ok((12, false)),
        h @ 1..12 => Ok((h, false)),
        12 => Ok((12, true)),
        h @ 13..24 => Ok((h - 12, true)),
        _ => Err("Invalid hour".into()),
    }
}

It appeared as x1000 slower than AI generated function. It means that my code quality is too low comparing to AI, although I still like my code.

My advice is - don't do it.

Rust doesn't have a good way of abstracting this. Using traits for numeric types is going to make the implementation painfully abstract. It will make type inference at the call site weaker too.

Macros are less painful. libstd uses macros for it's implementation of methods on all integer types. But they're syntactically ugly, and can be annoying when line numbers in type errors point to the shared macro definition, because different type-specific functions generated don't have their own line numbers. And they multiply amount of code to compile, costing build time and binary bloat.

If the code can work reasonably with one specific type, just use that type.

Couldn't help but remember this particular blog post. Throwing around raw u<N> as '24 hour clock' before converting it ad-hoc into just as raw of (u8, bool) only to re-validate whether or not it fits into a 0..24 (potentially multiple times, further implying that the hour_24 your fn receives as an argument might not actually be a valid 24-hour interval?), smells rather off.

Personally, I'd reach for something akin to:

#[repr(transparent)]
struct Hours24(u8);

enum Hours12 {
    AM(u8),
    PM(u8),
}

impl Hours24 {
    fn hours_24(&self) -> u8 {
        self.0
    }
    fn hours_12(&self) -> Hours12 {
        let h = self.0;
        let m = h % 12;
        let z = (m == 0) as u8;
        let h12 = m + z * 12;
        match h < 12 {
            true => Hours12::AM(h12),
            false => Hours12::PM(h12),
        }
    }
    fn parse<N>(n: N) -> Result<Self, N>
    where
        u8: TryFrom<N>,
        N: Copy,
    {
        Ok(match u8::try_from(n) {
            Ok(h @ 0..24) => Self(h),
            _ => return Err(n),
        })
    }
}

Not sure how you got your own version to run "1000x slower" either. A quick benchmark of the above against the AI/non-AI versions you've mentioned doesn't look nearly as terrible:

Summary
Warming up ...

--- Benchmark results (10000000 iterations per run) ---

Option #1 (Hours24::hours_12)
  min:  3.333331ms
  max:  3.500936ms
  avg:  3.39191ms
  per iteration:  0ns  (β‰ˆ 0 ns)

Option #2 (modulo based)
  min:  4.756486ms
  max:  5.634857ms
  avg:  5.10853ms
  per iteration:  0ns  (β‰ˆ 0 ns)

Option #3 (exhaustive match)
  min:  11.280104ms
  max:  13.468998ms
  avg:  12.897871ms
  per iteration:  1ns  (β‰ˆ 1 ns)

Although it's a good solution, it has a certain overhead for my use cases. I frequently ask my girlfriend :slight_smile:

  • What time is it?
  • It’s ten past three, she answers.

We, humans, usually omit parts which can be obtained from the current environment. Unfortunately, Rust doesn't support an ability to ignore enum type and return only the filler. If you know how to bypass the limitation, please share.

Regarding the poor performance of the human's solution against AI's. I wrote a simple benchmarking code:

let now = SystemTime::now();
    let mut sum = 0;
    for _ in 1..1_500_000 {
        for h in 0..24 {
            if let Ok((h, _)) = convert_24_to_12(h) {
                sum += h;
            }
        }
    }
    let duration = now.elapsed();

    println!("{} Time elapsed: {:?}", sum, duration);

When I run it against my human code, I'm getting:

233999844 Time elapsed: Ok(182.563329ms)

But AI gives me much better number:

233999844 Time elapsed: Ok(875ns)

I can only guess that a pattern matching is an expensive operation in Rust.

let Hours12::AM(h) | Hours12::PM(h) = hours12; is about the shortest it gets.

I would not trust this benchmark. It's more likely that the compiler has applied a loop optimisation to the modulo version, which it could not in normal use. That's why it's important to black box your inputs as marlez did, so the compiler cannot apply these unrealistic optimisations to your benchmark