Stream: beginners

Topic: Numbers friendly usage


view this post on Zulip Kristian Notari (Oct 19 2022 at 10:57):

Hello everyone, first time posting here and first time using Zulip (actually)!

I have gone through the tutorial section on numbers and I've found you can be quite specific about the numbers you're using in your Roc program (u8, u16, i32 and so on), but you can also use Num, Int and Frac to be open to any kind of numbers, integers and fractions respectively.

From a high level program perspective, ignoring performance, using Int and Frac when we need to differentiate between integers and fractions can be done through all the program's code without ever mentioning specific number types like u8, u16, i32 and so on and without the need to think about overflows?

view this post on Zulip Anton (Oct 19 2022 at 11:22):

Welcome @Kristian Notari!
I'm not the expert on this but I think the compiler does end up creating specific variants of functions. So when the user sees a function that uses Int the compiler will end up constraining it to say i64 if it detects that is the type that will end up as input or output. So overflows can definitely happen. I believe this is a lot faster than trying to detect and patch overflows on the fly by dynamically moving something to a bigger type, which is why this choice was made.

view this post on Zulip Folkert de Vries (Oct 19 2022 at 11:33):

also, overflow panics in roc with the default infix operators

view this post on Zulip Folkert de Vries (Oct 19 2022 at 11:33):

so overflows won't ever happen silently, unless you explicitly opt into that

view this post on Zulip Kristian Notari (Oct 19 2022 at 12:29):

Ok so Roc's take on this is somehow more low-level than something else where, for example, by default the Num type is treated as the widest possibile implementation of a numeric value, let's say i64 or f64.

Cause I'd like to take advantage of runtime speed that Roc is giving while still offering a simple and intuitive functional like writing and modeling experience. I'd like not to base my thoughts on "will this crash at runtime due to some overflow happening" but rather on "if it compiles it works".

I don't see how I should be peacefully writing programs with these premises, from an abstract high level perspective I mean. I'd like to know more on how should I approach this topic when writing programs.

Let's say I have my custom add function (for some reason):

add : Num a, Num a -> Num a

Should I consider it safe to add two i8 values? Like what happens if the only point in my code where I use this function is to calculate:

result = add 127i8 127i8

Should I expect the result to be a crash with overflow due to the usage of i8 or should it be 127 + 127 = 254?

view this post on Zulip Kristian Notari (Oct 19 2022 at 12:35):

Oh I've just seen Num is typed, so I guess that when using i8 values they overflow since i8 addition is used

view this post on Zulip Folkert de Vries (Oct 19 2022 at 12:35):

yes, exactly

view this post on Zulip Folkert de Vries (Oct 19 2022 at 12:35):

The actual signature of add will be

add : Num a, Num a -> Num a

which we can write more explicitly as (this is not actual roc syntax)

add : forall a. Num a, Num a -> Num a

So now when you go to apply this function to an actual value, the type checker will make the types line up. The I8 type is an alias for Num Signed8, so the actual add you call has type

add : Num Signed8, Num Signed8 -> Num Signed8

view this post on Zulip Folkert de Vries (Oct 19 2022 at 12:36):

and that call will overflow with the inputs you picked

view this post on Zulip Folkert de Vries (Oct 19 2022 at 12:36):

now, what happens depends on your implementation of add here

view this post on Zulip Folkert de Vries (Oct 19 2022 at 12:36):

with add = Num.add, it overflows

view this post on Zulip Kristian Notari (Oct 19 2022 at 12:36):

Yeah I was just pretending to wrap the Num.add implementation basically, so I expect an overflow, so a runtime crash with no compile-time warning, right?

view this post on Zulip Folkert de Vries (Oct 19 2022 at 12:36):

but we also have addSaturated or addWrap

view this post on Zulip Folkert de Vries (Oct 19 2022 at 12:37):

yes

view this post on Zulip Folkert de Vries (Oct 19 2022 at 12:38):

we've made this choice quite deliberately. Overflow of an i64 is rare, but if it happens you don't want that to happen silently, even in production

view this post on Zulip Brian Carroll (Oct 19 2022 at 12:38):

Cause I'd like to take advantage of runtime speed that Roc is giving while still offering a simple and intuitive functional like writing and modeling experience

There is a direct tradeoff between speed and high-level convenience. No language or system can give you maximum speed and maximum convenience at the same time.
The Num package has functions that cover various options

view this post on Zulip Richard Feldman (Oct 19 2022 at 12:49):

also worth noting that numbers in mathematics are infinite, but computers have finite resources. So there does not (and can never even theoretically) exist a computer that can offer a programming experience where you never think about overflow and it always works out fine.

view this post on Zulip Richard Feldman (Oct 19 2022 at 12:50):

so it's a question of where you decide to draw that line. Python for example has "arbitrary ints" - which means "overflow happens when you run out of memory"

view this post on Zulip Anton (Oct 19 2022 at 12:51):

Another option could be to write your own prettySafeAdd: Num a, Num a -> i128.

view this post on Zulip Richard Feldman (Oct 19 2022 at 12:51):

yeah I've actually been thinking that defaulting to I128 would be a better default

view this post on Zulip Folkert de Vries (Oct 19 2022 at 13:10):

really, how does one overflow an i64 by accident in a way that it would not with an i128

view this post on Zulip Folkert de Vries (Oct 19 2022 at 13:11):

seems like it would just provide more space to hide bugs

view this post on Zulip Kristian Notari (Oct 19 2022 at 13:13):

Thanks everyone.

I'm not concerned by speed, rather by high level abstraction and "not have to think about low level stuff under the hood". With this system, if I expose some API that uses two numeric inputs in a potentially unsafe way (where unsafe mean potential overflows), I can't really be sure the user it's using the API correctly cause if I use Num a as my types and I'm not informing them to the risks (within the type level, so no compile-time errors on their part), if they use i8 values they are much more likely to encounter overflow issues.

So I'm basically leaving that "risk" to the user without letting him know, cause there's no contract/types involved, just the Roc docs saying it's potentially unsafe to do numeric operations, right?

view this post on Zulip Folkert de Vries (Oct 19 2022 at 13:14):

if your user explicitly picks I8, which is not the default anywhere, and then use the default operators then it is likely they run into overflows and hence crashes, yes

view this post on Zulip Folkert de Vries (Oct 19 2022 at 13:15):

and actually, very likely, so will your library code. Being generic over numbers breaks down very quickly

view this post on Zulip Kristian Notari (Oct 19 2022 at 13:15):

I mean, if my exposed API is "doSomethingWithNumbers" the user doesn't know addition and potential overflows are a thing, it can't be certain

view this post on Zulip Folkert de Vries (Oct 19 2022 at 13:15):

it is nice for the standard library api, but your own code should usually quickly just pick one representation

view this post on Zulip Folkert de Vries (Oct 19 2022 at 13:16):

correct. On the other hand, there is no alternative. You could be using wrapping addition and that may not be what your user wants/expects either

view this post on Zulip Kristian Notari (Oct 19 2022 at 13:17):

Apart from numeric operations, are there any other "common enough" cases where potential runtime crashes can happen that are not reflected into the type system nor compile time errors?

view this post on Zulip Kristian Notari (Oct 19 2022 at 13:18):

(I'm not speaking about List being instantiated with more than the process memory capacity, but more common and probable things)

view this post on Zulip Kristian Notari (Oct 19 2022 at 13:25):

By the way, in order to have 100% safe exposed API (except for some more rare 128+ overflows) I should always accept a signed/unsigned number of the most wide numeric type and so force the user to convert their numbers to my accepted (the widest) numeric type (in Roc being I/U 128 and Dec).

And to be the most user friendly and safe possible, I should accept whatever the user is passing to me and then convert them instantly in their widest counterparts and use them internally.

(I mean, ignoring performance/memory)

view this post on Zulip Ayaz Hafiz (Oct 19 2022 at 13:31):

Is using something like “addChecked”, which will return a result and an Err on overflow, an option for you?

view this post on Zulip Ayaz Hafiz (Oct 19 2022 at 13:31):

I understand the ergonomics suffer a bit because your users will get a Result rather than a number directly, but it will be a safe API.

view this post on Zulip Kristian Notari (Oct 19 2022 at 13:36):

I'm not concerned about returning a Result. It's the opposite in fact. If I can reflect the risks at the type level, I'm more than glad to do so and I'm likely going to do so if I ever have to write something like that down (whenever reasonable to do so).

My concerns are that is not going to be that common to do numeric operations with results, as a community, since the language also have "unsafe" counterparts ready to use.

view this post on Zulip Kristian Notari (Oct 19 2022 at 13:38):

Personally, I was expecting a "safe" first approach on this by Roc, like defaulting to 64 or something like that when being generic over numeric types as said here, just to exclude some minor but more frequent bugs with lower than 64 bits numbers

view this post on Zulip Brendan Hansknecht (Oct 19 2022 at 14:04):

We do default to 64bit types when none is picked. Which means unless a user chooses otherwise it is relatively safe.

If you want safety, there are simply some things you can't be generic over (without extra checks). By choosing Num a as a type in a function signature, you are accepting the risk that a user might pass in an I8 or a U128. In many cases, there is likely no safe and fast way to deal with both of those types.

Compared to most systems languages I would say that crashing on over or underflow by default is extremely safe because it stops the application before something goes wrong.

That being said, it obviously is less safe than something like python for numerics. I guess you could try and argue JavaScript as well, but JS is always floating point numerics are another can of worms.

view this post on Zulip Brendan Hansknecht (Oct 19 2022 at 14:07):

If we wanted to enable safer Roc code (from the numerics perspective), we could add a flag to enforce checked math. I personally find that way too tedious because your code becomes riddle with results, but it would enable generic and safe code.

view this post on Zulip Kristian Notari (Oct 19 2022 at 14:17):

Yeah I was not criticizing. It was more an opportunity to know the reasons why. I assumed by default the most safe way of doing things was the go to for Roc and I was excepting not to incur in “unsafe runtime crashes” explanations directly in the tutorial. But I guess those number representations decisions also help with performance which is one of the goals

view this post on Zulip Kristian Notari (Oct 19 2022 at 14:18):

Are there other untyped run-time issues besides running out of memory?

view this post on Zulip Brendan Hansknecht (Oct 19 2022 at 14:20):

I wonder if we should have a CheckedNum a type or something similar that removes default math operators. Forces the user to directly call addWrap, addChecked or addSaturated. That at least would give a very clear opt into safety. Of course this could easily be a userland library, though slightly less convenient as such.

view this post on Zulip Brendan Hansknecht (Oct 19 2022 at 14:21):

Other cases for panic: division and modulus by zero, i think. Maybe some float related stuff, but I'm not sure in the end what all has been setup with floats. That said, we default to a decimal fixed point that won't hit as many issues as floats

view this post on Zulip Kristian Notari (Oct 19 2022 at 14:26):

From a community perspective, the more alternatives you make the less clear intents and code become. Today is checked math, tomorrow is an alternative formatter, two months from now is a “legacy-something” compiler flag, and so on

view this post on Zulip Kristian Notari (Oct 19 2022 at 14:27):

That’s why I was expecting checked math by default, but that could be seen as too rigid from a practical perspective

view this post on Zulip Kristian Notari (Oct 19 2022 at 14:27):

(Checked math or at least checked math for sub wide number types like i8 and similar)

view this post on Zulip Brendan Hansknecht (Oct 19 2022 at 14:28):

We originally had some checked math by default. Like with division by zero. In most cases, is was just an inconvenience and user would end up writing: Result.withDefault (a / b) 0 # b should never be zero. It just appears all over certain code.

view this post on Zulip Brendan Hansknecht (Oct 19 2022 at 14:31):

(Checked math or at least checked math for sub wide number types like i8 and similar)

I feel like that would be very confusing to users. Specifically because it would be inconsistent based on the type. Also, You wouldn't be able to use Num a anymore. It would need to do different things based on the version of Num a. So you would still need something like SubWideNum a and WideNum a to support something like that.

view this post on Zulip Anton (Oct 19 2022 at 14:34):

We originally had some checked math by default. Like with division by zero. In most cases, is was just an inconvenience and user would end up writing:

Yeah, I don't think we could call roc beginner-friendly if we decided to do that.

view this post on Zulip Kristian Notari (Oct 19 2022 at 14:34):

Yeah it's suboptimal whatever way you choose I guess. Picking the "right" suboptimal for is not easy task, but I'm interested in following what would end up being the "common way of doing things".

view this post on Zulip Kristian Notari (Oct 19 2022 at 14:39):

Brendan Hansknecht said:

We originally had some checked math by default. Like with division by zero. In most cases, is was just an inconvenience and user would end up writing: Result.withDefault (a / b) 0 # b should never be zero. It just appears all over certain code.

From my personal perspective, this is the most "correct" way of doing such things. As in any other domain where things can fail, here you have it described in the type system for you to help reason about what you're doing. The number of times you encounter that doesn't matter. Moreover, the "beginner-friendliness" of this approach I guess is debatable. Cause nowadays more and more people are thinking about their programs in a more safe and typed way. I'm no saying everyone is expecting checked divisions, but it's not as strange as it was 10 years ago. Being an "early" adopter of "everything checked" by default sounds great to me. But that's purely personal

view this post on Zulip Brendan Hansknecht (Oct 19 2022 at 14:40):

The issue is that you end up opting into a garbage value. That is not safe, but that is the default of what most people would write.

view this post on Zulip Brendan Hansknecht (Oct 19 2022 at 14:41):

So it was safer to panic unless someone truly wants to handle the check

view this post on Zulip Brendan Hansknecht (Oct 19 2022 at 14:41):

But yeah, I can see the tradeoff. I think it just makes numerics pretty impractical to use, but that is just an opinion

view this post on Zulip Brendan Hansknecht (Oct 19 2022 at 14:58):

I am scanning the code trying to collect possible panics (we should make a doc with a list somewhere if it doesn't already exist):

I think that is a correct list, but I easily could have missed something. So really just numerics.

view this post on Zulip Kristian Notari (Oct 19 2022 at 14:59):

Yeah I got your point and I agree with the impractical usage. If the list is limited as you're saying I guess it's a no brainer and it's good as it is, the tradeoff is more than acceptable

view this post on Zulip Kristian Notari (Oct 19 2022 at 15:00):

Still, for math operations the alternative checked functions are there right?

view this post on Zulip Brendan Hansknecht (Oct 19 2022 at 15:01):

Yep

view this post on Zulip Brendan Hansknecht (Oct 19 2022 at 15:01):

all math functions that could panic have alternatives

view this post on Zulip Brendan Hansknecht (Oct 19 2022 at 15:01):

view this post on Zulip Kristian Notari (Oct 19 2022 at 15:03):

So I guess "all checked math functions" label on packages is the new "no unsafe" of Rust libraries then :D

view this post on Zulip Brendan Hansknecht (Oct 19 2022 at 15:06):

Haha, I guess. This is definitely something to re-evaluate as Roc grows. Hard to say what the right option is.

For example, it may be that libraries should be forced to use checked math, but applications can use panicking math. I think it is much more common in applications to just ignore cases you don't expect to happen. Helps quickly develop and iterate. With libraries, maybe we want more guarantees in general.

The mix of high and low level use cases is always a balance in Roc.

view this post on Zulip Martin Stewart (Oct 19 2022 at 16:27):

What if instead of Result.withDefault (a / b) 0 you'd instead write something like

lotsOfMath : UnsafeNum I64
lotsOfMath = a / b + 5 * c

match lotsOfMath with
    Ok ok -> { data & value : ok }
    Err _ -> data

In other words, we are doing math as if it was a transaction that we then check at the end to see if it worked out (and we can't forget to check because the data structure we are assigning it to has Num I64, not UnsafeNum I64)

view this post on Zulip Anton (Oct 19 2022 at 16:29):

Interesting :)

view this post on Zulip Kristian Notari (Oct 19 2022 at 16:40):

Which is basically leveraging chainable computations capabilities of mathematical functions in an upper level “Expression : Result Num *”

view this post on Zulip Kristian Notari (Oct 19 2022 at 16:42):

That would be like chaining tasks or results, so going from values to expressions, staying there as much as you can then come back to either a value or an error

view this post on Zulip Kristian Notari (Oct 19 2022 at 16:43):

But it has the same drawbacks as checked operations when you only have to do small math operations in different parts of your code

view this post on Zulip Kristian Notari (Oct 19 2022 at 16:43):

A lot of unwrapping (and potentially wrapping)

view this post on Zulip Kristian Notari (Oct 19 2022 at 16:44):

Which I’m all for, btw 😅

view this post on Zulip Brendan Hansknecht (Oct 19 2022 at 17:18):

That is a much more usable idea. Does cost in storage space though (every number type will take up 2x the space in a List). Also, without a smart compiler to skip operations could spend a lot of time just passing around an error result.

Also would be less efficient performance wise, but only minorly so.

view this post on Zulip Brendan Hansknecht (Oct 19 2022 at 17:19):

We actually discussed doing something similar for floats at one point. Though in that case the discussion was floats are for speed to not have issues with NaN and etc, just use Dec.

view this post on Zulip Brendan Hansknecht (Oct 19 2022 at 17:19):

So different context for sure.


Last updated: Jul 06 2025 at 12:14 UTC