Hacker News new | past | comments | ask | show | jobs | submit login

> Seems all that could have been avoided by using rationals in general

It's 2023: I'm still surprised that so many new languages/platforms still only provide only basic integer and IEEE-754 types for numerics: Rust, C#, Java, Swift and others still lack built-in, runtime-provided, nor even standard-library-provided rational types, which I assume really should be the preferred type for most business/domain/application values, exactly for things like RTS game unit health, for example. (and Java's lack of operator overloading makes this even more painful).

----

On a related note, can we agree that an application-programming language today should also include signed-and-unsigned "money-safe" decimal types, and IEEE-754 types should be smarter about when NaN can happen - and it should support interval types (and evaluating interval-type-based contract invariants): this would eliminate whole classes of bugs in the first place (too many programmers think single/double is appropriate for storing currency values, ugh).




> It's 2023: I'm still surprised that so many new languages/platforms […] still lack built-in, runtime-provided, nor even standard-library-provided rational types

Because numerators and denominators can blow up easily, rational types effectively require storing them as bigints. That makes them bad from a performance viewpoint.

Assuming your application will work fine with fixed-size rationals (which, IMO, is highly unlikely), the natural way to store rationals in fixed-size storage wastes lots of room.

For example, in a (int32 nominator, int32 denominator) pair, there are about 2³² ways to encode ‘1’ that way, 2³¹ to encode ½, etc. I also think such a fixed-size rational type isn’t very natural to work with

Rationals also require regularly computing a gcd when you add (1/3 + 1/6 isn’t 9/18 but 1/2) or multiply (10/21 × 7/5 isn’t 70/105 but 2/3) them, slowing down things more (you can use heuristics to avoid some of those simplifications, but not doing one when that’s possible may mean your rationals keep huge numerators and denominators, slowing down operations)


> Because numerators and denominators can blow up easily, rational types effectively require storing them as bigints. That makes them bad from a performance viewpoint.

While hardware support for full number towers might be neat, I don't think anyone is suggesting that int and floats should not have language/standard library support - only that there should be a blessed option for precise arithmetic.

Usually the slow correct answer is preferable to the quick wrong answer (esp in business logic).


I'm (vaguely) surprised there hasn't been a hardware type for rationals. Then the GCD operation could be done in hardware and everything would be fast (at least as fast as floats). Not surprising, because adding new hardware data types is hard, but the financial institutions would benefit from something like that.

A rat64 could be:

- 1 sign bit

- 3 format bits. Determines where the decimal is located, e.g. 30.30 vs 52.8.

- 60 bits of numerator and denominator. The split is determined by the format bit. 50.10 would be handy for any percent/ppt operations (e.g. Most USD operations)

Might need an error bit, but that's my 5 minute gist.


> Then the GCD operation could be done in hardware and everything would be fast (at least as fast as floats).

Could it? Is there a fast way to do GCD in hardware?

Googling gave me https://en.wikipedia.org/wiki/Binary_GCD_algorithm, which needs O(log₂(max(u, v))) operations to compute gcd(u,v), but I wouldn’t know whether that’s the best we can do in hardware, or whether that’s ‘as fast as floats’.

Also, I don’t see how your format describes a rational type. Rationals store numbers as p/q for integer p and q, not with a decimal point.


Sorry, abuse of notation. 30.30 would be 30 bits numerator, 30 bits denominator.

I would imagine it would not need to fully reduce after every operation, there's probably tricks where if you know the factors going in, there will be obvious ones after multiplication/division.

It's not my best idea :p


> 60 bits of numerator and denominator.

The trouble is this isn't enough, even for ordinary usage. The numerator and denominator grow in proportion to the total number of operations you have performed. For example, if you start with `1` and then multiply by `80/81` a thousand times, you get a number that's around 1e-6, but when expressed as a rational the numerator and denominator have hundreds of digits:

https://www.wolframalpha.com/input?i=%2880%2F81%29%5E1000


But how often are people using decimal types to do that? Most of the uses I could see this type being used for - currency, percent scaling, datetimes, audio/video codec frame rates - all are basically fixed point operations. Anything involving powers of 80/81 would probably need bigint based rationals anyways.

Actually if you had an int64 type which was scaled by flicks, that'd give you quite a lot of latitude for most day to day stuff.

https://en.m.wikipedia.org/wiki/Flick_(time)


Yeah, fixed point is different from rational, and all those examples you gave sound to me like fixed point. And that can be implemented efficiently without dedicated hardware support: the denominator is fixed, and you store the numerator as an integer.

A 1/3 off discount on a $10 item is $6.67 (or $6.66 if rounding in the customer's favor), not $10/3.

(Except datetime, did you mean timestamp? A timestamp is an instant in time, and it often makes sense to store it in high precision because you're saying exactly when something happened. A datetime is for communicating between humans who are using some particular calendar; it rarely makes sense to have more than minute precision.)


Aren't rationals subject to exponential blowup in storage/precision? IMO the best approach for business logic values is picking the smallest meaningful unit and representing it with integers. Precision and storage requirements become very easy to reason about.


> IMO the best approach for business logic values is picking the smallest meaningful unit and representing it with integers.

How would you design an RTS unit stats system to avoid this AoE Monk HP bug using only integer types?


`(new_maxhp * old_hp) / old_maxhp` avoids the original problem when using integers. Though you still need to make sure your type is big enough to not overflow.

Or using fixed-point (which are a relatively simple abstraction over integers): `old_hp * (new_maxhp * old_maxhp)`.


They probably just added: return ROUND()... and went back to play


How much health precision do you need? Make hp an int. Or like a /64 fixed point.


Congratulations, you just introduced a new damage threshold / resistance threshold / etc where things effectively do no damage.

Fixed point would not have helped here. Integer would only have helped because dividing first would break immediately.


     max(1,...


Well, in this case, if a unit is converted to a different type and back, it would just keep its (absolute) value of HP (in this case 1), no need to scale anything.


You haven’t thought it through yet. In this case, HP _is_ an integer, and so is the MaxHP for the unit type. And yet the bug still occurs! Storing the HP as an integer does not prevent the problem from ever occurring, because fundamentally the user still expects HP to be proportional.

That is, suppose a unit is sitting there at full health, and you research a tech that increases that unit’s max hp? Should the unit now be at less than full health, as if it had been in combat? Users probably don’t like that.

Suppose it is at full health, and then it gets downgraded to a type with less maximum health. Does it stay at it’s current HP? Users probably won’t like being attacked with supercharged units that have extra HP; they’ll think that the other player was cheating somehow.

What if it is damaged and at half health, then gets upgraded. Should it be fully healed? Gain HP equal to the difference between the new and old maximum HP? Or should it gain half of that, so that it stays at half health?

Or perhaps HP is too limiting, and the game should do what Dwarf Fortress does. DF knows the approximate surface area of every body part (based on each creature’s body plan, plus individual stats such as strength, fatness, size, etc), and the size of every weapon. Every attack therefore deals damage to a certain area, measured in square inches, and individual body parts will be destroyed once a sufficient percentage of that surface area is damaged by wounds.

Or maybe you are designing this game in the 90’s, and you have to worry about squeezing unit updates for 200 units per player into the bandwidth provided by the average modem of the day (probably 28.8kbaud), so you stick to one integer because it’s the simplest think that can possibly work, and the number of bytes per update can be calculated in advance. And then some other schmuck gets stuck with the job of handling unit type changes (but be warned that the schmuck might be yourself in six months).


>That is, suppose a unit is sitting there at full health, and you research a tech that increases that unit’s max hp? Should the unit now be at less than full health, as if it had been in combat? Users probably don’t like that.

>Suppose it is at full health, and then it gets downgraded to a type with less maximum health. Does it stay at it’s current HP? Users probably won’t like being attacked with supercharged units that have extra HP; they’ll think that the other player was cheating somehow.

>What if it is damaged and at half health, then gets upgraded. Should it be fully healed? Gain HP equal to the difference between the new and old maximum HP? Or should it gain half of that, so that it stays at half health?

     if (oldHP == maxOldHP) { newHP = newMaxHP }
     else { newHP = max(1,scale(oldHP,oldMaxHP,newMaxHP))
not exactly complex


Sure, if you fully predict all bugs in advance and compensate for them, nothing is complex, but that's not really feasible.


>Sure, if you fully predict all bugs in advance and compensate for them, nothing is complex, but that's not really feasible.

Uhh, last time I checked, that was literally my job, and specifically what I went to school for!


Not exactly complex, but you posted too quickly to have found the best solution.

A better solution is to kill the unit when it drops below zero HP, not when it drops below 1.0; scaling will never move the current HP past zero in either direction. Having 0.9999994 HP instead of 1.0 HP would not cause any problems then; the unit is still one hit away from death in either case.

The best solution is probably to store the current HP as a percentage of the max, because then you never have to rescale it in the first place.


> The best solution is probably to store the current HP as a percentage of the max, because then you never have to rescale it in the first place.

Doesn't this solution make the more common calculations more expensive, complicated, and error-prone to make one rare calculation easier? It seems like far more of the operations on the unit's current HP will be "gets damaged by X hp" or "gets healed by X hp", both of which would require the equivalent of the above conversion to establish a result.


If you’ve changed HP to a percentage, why wouldn’t you also change the damage and other related numbers (such as damage reduction and so on) to percentages as well? Nobody would be dumb enough to store them in different units and then convert every time they manipulate them.


> why wouldn’t you also change the damage and other related numbers (such as damage reduction and so on) to percentages as well?

Because a sword might do 5HP of damage, not 10% of anyone's damage (whether they've got 50 or 5000HP). Otherwise, hit points are meaningless: 10 attacks with a 10%-damage weapon kills a lowly serf or a mighty dragon.

And because damage reduction might reduce a fixed amount of damage from an attack. Etc.


Obviously a dragon doesn’t get damaged at all by a puny little sword. You can’t just say that hitting a dragon with a sword a thousand times would kill it, when none of the attacks can get through the dragon’s armored hide. On the other hand, it will die immediately if pierced by an arrow provided that arrow hits the one spot where the hide is missing a scale. The dragon has 100% DR, except in that one spot.


> Obviously a dragon doesn’t get damaged at all by a puny little sword. You can’t just say that hitting a dragon with a sword a thousand times would kill it, when none of the attacks can get through the dragon’s armored hide.

https://www.youtube.com/watch?v=9VDvgL58h_Y


:D


> A better solution is to kill the unit when it drops below zero HP

Then you have the situation where units are displayed as having 0 HP but are still alive.


So what? The players already know that attacks deal fractional HP damage.


> The players already know that attacks deal fractional HP damage.

Top-end players who analyze the engine enough do.

Otherwise, they shrug and say "sometimes 1, sometimes 2 HP"

And even so, we're used to seeing 0/50HP and it meaning "dead" across many games. Seeing 0/50HP and it meaning "just a small amount of HP left" isn't ideal.

Not to mention that weird situations with 5.551115123125783e-17 HP remaining aren't great either (where a player may end up with effectively an "extra" hit point).


Again I say “so what?”. The unit is one hit away from death either way, and the player will know it.


At this point, with your Smaug-related comment, etc-- I've become convinced you're trolling.

But just to humor you: the desire is to have a system where floating point neither surprisingly kills nor gives characters extra hit points. There's a lot of subtlety in this. Schemes where all damage are percents don't preserve the essential pieces of RPG combat systems. Moving the threshold from 1 to 0 HP violates the conventions of the art and doesn't eliminate the problems (you can still end up with hit points epsilon away from 0, instead of 1).


The problem isn’t hitpoints that are an epsilon away from 1, it’s that an operation which looked innocuous moved the HP to an epsilon _below_ 1. That would never happen with 0, however, as multiplication and division of positive numbers always gives a positive result.

And in D&D, incapacitation happens when a character drops below 0 HP, and death happens once they drop below -10HP. There is no grand convention that all RPG games follow here; each game is making things up as they go. They picked badly when they designed Age of Empires, and that’s ok. It’s not the end of the world. Just know that we can do better if we are paying attention.

And I am serious when I ask “so what?”. Why is it so bad if the game displays 0/55HP? The players all know that fractional HP damage exists, and they can see that their unit is still alive, so what is the problem? They will be at least as amazed that their unit survived as they currently are when they see a unit at 1/55HP.

I am likewise serious about Smaug; there is no way that combat makes any sense if Smaug has a large number of hitpoints or that any weapon at all can damage him. Using hitpoints as an abstraction completely loses all of the flavor of Tolkien’s original descriptions. A better model is one using damage reduction and sensible internal organs; a strike that pierces the heart will kill even a dragon.


> however, as multiplication and division of positive numbers always gives a positive result.

Depends on what you do with subnormals and underflow, etc.

> There is no grand convention that all RPG games follow here

Computer RPGs have shown 0/812 for dead for ... forever.

> The players all know that fractional HP damage exists

I think most players will be confused. And for what, to make it "safe" doing floating point operations in the wrong order? (I doubt it's still safe, overall).

> I am likewise serious about Smaug; there is no way that combat makes any sense if Smaug has a large number of hitpoints or that any weapon at all can damage him.

Common mechanisms: an armor class that makes it hard to hit; a fixed amount of damage reduction so that a needle does nothing; a lot of hitpoints.

> and sensible internal organs;

It gets excessively complicated and annoying. Battletech is already excessively complicated, and doesn't go quite as far as you're advocating for here.


Did this exponential blowup ever prove troublesome in practice? I think it can be easily solved by strategically placed rounding steps. But explicit rounding is advisable in many cases anyway.


But strategically placed rounding was in fact the solution to the original problem that in this thread Rationals were proposed as solving...

Rational types are not super popular and don't get used that much, most (not all) people that end up using them do it very intentionally and consciously and know what they're dealing with -- if they were more ubiquitious, I suspect more trouble would be seen "in practice".

While it's probably true that any digital number format will have edge cases that in fact come up in practice, my personal choice of "Why the heck isn't this more popular, why doesn't every language support it, why isn't it in fact the default representation of a numeric literal" -- is floating point "decimal" types, like ruby (or I think Java?) BigDecimal, rather than rational. I think they mostly work matching programmer's mental models of numbers, and for many/most common uses on 2023 platforms the performance is just fine. (this would not have been true 30 years ago).


Or it's vendors that don't care about correctness, like providing decimals by default and high performance floating point as an "optimized" option. Python did something like that by using bigints as a default for integers.

What would not have been true 30 years ago? I remember "real" fixed-point type built-in in Turbo Pascal and explicitly documented as suitable for money. Common Lisp has had first class fractions support since the start.


Slight correction, from a former Turbo Pascal (and subsequent Delphi) programmer...

"Real" types were platform-dependent floating-point types, not suitable for monetary calculations whatsoever. and would map to either Single or Double depending on the underlying CPU architecture. Sort of like a C-style "int" that would map to an 8-bit, 16-bit, 24-bit, 32-bit, 36-bit, 60-bit, 64-bit etc integer, depending on the compiler and the compile target.

Turbo Pascal did have an 8-byte, fixed point, "Currency" type suitable for monetary calculations, however using it was very, very slow compared to pure floating point ops - just as the comment you replied to suggested. If that weren't enough, library support (both built-in and 3rd-party) for math and other utility functions was either limited or non-existent.


Slight correction from an Turbo Pascal expert ;-)

Turbo Pascal did NOT have an 8-byte fixed point "currency" type suitable for monetary calculations. It did have an int64 type called "comp" though which was handled as a float by the FPU and hence not slower than the types "single" (f32), "double" (f64) or "extended" (f80).

IIRC, "currency" came with Delphi V2.0 (or even later), but then still it wasn't slower than other floats when you did heavy calculations with it as it was also handled by the FPU. Only reads and writes from and to such variables were expensive as there was always a scaling (multiplication by 1e4 and 1e-4) involved — internally it was that int64 "comp" type. (But here I might be wrong, I never really used "currency", I disassembled lots of Delphi binaries with lots of different data types as I wanted to know how the compiler worked. Today however my Delphi knowledge is quite fuzzy).


I meant to suggest that 30 years ago the performance difference between "floats" (floating-point binary) and "BigDecimal"-style arbitrary-precision floating-point decimal would have been much more significant to many more real-world use cases, compared to now. So that may have been a reason not to make them the default when you simply write a literal `5.4` in code, but that argument is less now.


Fun fact many processors actually support decimal types in hardware. I believe you can use them in c with _Decimal. Worked on my Ryzen 3700x and (if memory serves) my M1 mac


Due to the downvotes I looked into this again. You can use _Decimal32 etc in GCC on x86, but it is a soft implementation. I believe it is in hardware on some ibm platform, but definitely not on the M1


>It's 2023: I'm still surprised that so many new languages/platforms still only provide only basic integer and IEEE-754 types for numerics: Rust, C#, Java, Swift and others still lack built-in, runtime-provided, nor even standard-library-provided rational types

C# has the base-10 high accuracy decimal type: https://learn.microsoft.com/en-us/dotnet/csharp/language-ref...


There are two problems with the `decimal` type: it's not IEEE-754 compliant, and it is 16 bytes, which prevents atomic reads/writes (without locks).

This is a problem in one of my company's applications, as we have a background thread doing work entirely with `decimal` objects, but providing a readout for the GUI necessitates a lock on every write (even if never read), and another for the read by the GUI. There's barely any overhead (nanoseconds worth), but the logic to work around it is a pain, and, if you forget to actually use a lock, good luck with that bug.

.NET 8, however, will introduce[0][1] proper IEEE-754 decimal float types, including `Decimal32` and `Decimal64` which will allow atomic reads/writes.

[0]: https://github.com/dotnet/runtime/issues/81376

[1]: https://github.com/dotnet/runtime/issues/79004


> can we agree that an application-programming language today should also include signed-and-unsigned "money-safe" decimal types

You mean like COBOL?


Yep, like COBOL, or SQL.

But, in fact, I don't know of any language that doesn't. They are just not basic types.


Very few SQL implementations have unsigned integers (I think only MySQL+MariaDB has it out-of-the-box?). From my conversations with the MS SQL Server dev team, other than the overall lack-of-demand, it’s because ISO SQL leans very heavily on implicit conversions, and implicit conversions between signed and unsigned types are a massive pain to implement and gracefully handle all edge-cases and in a way that doesn’t behave too unexpectedly (and remember that most SQL language users are likely unfamiliar with all the gotchas of C-style unsigned integers: most SQL users are business analysts, not low-level systems programmers). Besides, if you just want to disallow negative values then just use a CHECK constraint.

———-

Also, in general, it’s not enough to just add loads of distinct numeric types to a language or library; in fact that’s probably the wrong thing to do as it burdens the programmer with having to make (often hair-splitting) decisions ahead-of-time - instead I’d like the language to have me set-up high-level constraints/invariants on a numeric parameter, local, or field and then the compiler chooses the best low-level implementation based on those constraints (and constraint-inference a-la Hindley–Milner would be nice too)


ClickHouse has support for unsigned integer types: UInt8, UInt16, UInt32, UInt64, UInt128, and UInt256.

The main applications are: hashes and identifiers, e.g., visitor identifier in clickstream.


> On a related note, can we agree that an application-programming language today should also include signed-and-unsigned "money-safe" decimal types

I’m willing to bet that this has to do with how we historically have handled monetary values which is storing and computing it in the respective currency’s fractional unit. The advantage is that you can still do math operations directly on this value (as opposed to an object that contains it) and at most you’ll be off by, in the case of the US dollar, one cent. Because we cannot represent fractional units of the already smallest unit of currency, we have to choose a value anyway to charge or dispense. Unlike, say, if we stored it in dollars as a floating point and we can start compounding errors from the floating point type.

What interesting is that pre computers, we didn’t even treat currency values as an real decimal (for base 10 currencies) and the decimal point was simply a convenient way to store partial values. I note this because you don’t see old ledgers where they store more than 2 decimal places, therefore IMO, this was just an integer in disguise all along.

Old habits die hard?


> Because we cannot represent fractional units of the already smallest unit of currency, we have to choose a value anyway to charge or dispense. Unlike, say, if we stored it in dollars as a floating point and we can start compounding errors from the floating point type.

I've spent 20 years mostly working in finance. You'd be surprised at the prevalence of floating points used to represent currency. I cringe every time I see it, but it's surprisingly common (and wrong).

More correctly, pricing of securities (from exchanges) is done with integers and a scaling factor. The factor is typically static and doesn't need to be transmitted on every tick. The factor tells you effectively how many fractional digits are present (e.g. the actual price is multiplied by 10^-factor). Factors of 4 or even 6 are somewhat common, but some securities have to go with less precision. I remember in particular Berkshire Hathaway causing issues with overflow using 32-bit ints in the last decade (because they never split their shares, the share price is quite large).


You'd be surprised at the prevalence of floating points used to represent currency. I cringe every time

We all cringe but then there’s a “floating point or gtfo” ultimatum that most languages present to you. People would be happy to not use FP. But the reality is, you can have a monetary column in a 30 years old database engine, but not in a 5 years old language runtime. When you only have a hammer…


In that situation you’re meant to use integer-cents though.


I'm increasingly convinced that peoples' problems with 754 types are psychological. With integers, they are very careful about order of operations, precision, rounding, etc. With doubles, the average programmer fires and forgets. Sometimes that has bad consequences.

A decimal type is definitely necessary, and you can use IEEE decimal floating point for that, too! Other decimal types are very useful. Rationals are a different story: rational numbers are great for addition and subtraction, but if you're going to be doing a lot of multiplication and division, you are going to find yourself also doing a lot of slow gcd calculations to reduce/renormalize the fractions (incidentally, decimal types also need renormalization, but it's a lot cheaper). Most cases where rational types are used today are very careful about not having long chains of these operations.

As to NaNs: those are all considered pretty carefully, and 754-2019 actually reduced the amount of NaNs that propagate around quite a bit. For example, make sure you are using fmax(a, b) instead of std::max<double>(a, b).


> It's 2023

This is a game originally from 1999. Although i doubt it would make a difference.


Java pretty much provides BigDecimal for that


A related discussion about number types:

The Great Debate @ARITH23: John Gustafson and William Kahan

https://www.youtube.com/watch?v=LZAeZBVAzVw

Kahan points out disadvantages of interval maths.


why would rationals be good for RTS.

If all of your operations occur with some reasonable minimum denominator, just use ints. If not, that arithmetic is going to become unbounded really fast.


Foundation has the Decimal type (not technically part of the Swift standard lib, but in practice available for most uses of Swift).


> and Java's lack of operator overloading makes this even more painful

Why?


Some aspects of the problem, exagerated for effect: boolean isDead = (new I("0.9")).divide(new I("55")).multiply(new I("0.1")).lessThan(new I("1"))


Because you can't make your own types that work better.


Of course you can. You just can't use operators for them.

Unless you go Haskell where anything can be an operator name, operators are just a minor convenience hack .


So you can but you need to rewrite everything instead of just changing a type, and the code will look completely impossible to read.


And now all your simple scalar values need to exist on the heap as class object instances - and will incur plenty of runtime allocation costs for every arithmetic operation - or try to risk it with non-immutable heap-allocated objects, but that’s a recipe for disaster: no-one should ever think mutable shared state is a good first-design.

A lot of Java/JVM’s design decisions made sense in the mid-1990s, such as not supporting user-defined stack-allocated value-types and only supporting GC-managed objects - as main-memory was fast-enough compared to CPU registers - and many/most Java targets didn’t have any kind of CPU data cache - the idea of using the heap for basically everything wasn’t a bad design… back then. But now it is: high-perf code for the CLR means eliminating unnecessary GC allocation wherever possible - but in the JVM that isn’t possible.




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: