9
$\begingroup$

In C a signed integer is just a binary number and all binary numbers are valid integers. There is no NaN value.

C uses 2s complement for signed integers meaning there is one more negative than positive integers. It have been possible to reserve the most negative integer 100...000 to indicate an invalid integer. This would have provided a NaN value that could be returned from functions like atoi() as well the (OCD placating) feature of making the number of positive and negative integers the same.

I realise there may be no answer to the question why wasn't this done, but if there are reasons why this would have been a bad idea I would be interested to hear them. The question applies to any languages that represent signed integers in the same way that C does.

Update: Michael comments that the next version of C explicitly forbids the use of 1000...000 as an invalid value:

  1. TIGHTENING OF INTEGER REPRESENTATIONS
    3.1. Minimum values of signed integer types
    Even for two’s complement representation C17 allowed that the value with sign bit 1 and all other bits 0 might be a trap representation. We change this and are thereby in line with the changes in C++. We force that for integer types with a width of N the minimum value is forced to −2N−1 (and the maximum value remains at 2N−1 − 1).

I would be interested to hear why this decision was made. Well, it was made to comply with C++, but then the question is why does C++ do this?

$\endgroup$
12
  • 2
    $\begingroup$ The most negative integer is sometimes used to indicate a problem, such as in x86 when converting an out-of-range float (or NaN) to an integer, and that behaviour has leaked into higher level languages. That's not entirely what you describe here, but I mean to say that there is some level of precedent for treating the most negative value as indicating "bad value". $\endgroup$
    – user1030
    Aug 16, 2023 at 8:40
  • 13
    $\begingroup$ C doesn't actually require two's complement, it's just the most common implementation today; the next version is likely to mandate it, but that hasn't happened yet, and when it does it's actually going to ban using that value as a trap representation. $\endgroup$
    – Michael Homer
    Aug 16, 2023 at 8:46
  • 10
    $\begingroup$ I'm not sure what you have in mind for this. Processors don't have instructions for doing 2's-complement arithmetic where INT_MIN is supposed to be special, so there would be no hardware support for NaN-like behaviour; so either every arithmetic operation would have to be checked for NaN (which would be very un-C-like), or arithmetic on NaN could be undefined behaviour (making it the programmer's responsibility to not produce INT_MIN from arithmetic by accident), or it could be specified as arithmetically behaving like INT_MIN, in which case why make it a special case in the spec? $\endgroup$
    – kaya3
    Aug 16, 2023 at 13:14
  • 2
    $\begingroup$ For x = calc(a,b) * calc(c,d);, wouldn't a NaN return simply get destroyed by the multiplication? To avoid that, wouldn't a lot of extra code have to be generated with every operation? $\endgroup$ Aug 16, 2023 at 13:15
  • 3
    $\begingroup$ @Ray, only if the hardware has no support for a trap. It's not mandated that an implementation has NaNs for floating-point - they are provided only if convenient, so why would an integer NaN be different? That said, I'm not aware of any ISA that supports such a thing, hence no need to be able to represent it in C. $\endgroup$ Aug 16, 2023 at 13:59

8 Answers 8

13
$\begingroup$

C is about what the machine may have (even now)

A little history, before the answer. C is a direct successor of B, and B has really one type: the machine register, with then could act as a machine hardware number or a machine memory address (a.k.a., pointer).

That's it. All of everything should be implementable in terms of this simple abstraction. All variables, all parameters, all state is just abstracted hardware numeric integers with no size. And people are using this to write an entire operating system for a big computer that (for a time) has no hard drive.

One guy of the team took a vacation and when he returned, B was already transmuted to C, because they are writing another operating system for another machine. By then it was observed that:

  • The machine abstraction not knowing the machine register size facilitates a lot of code re-utilization porting;

  • New machines are including new hardware support for "advanced" thinks like addressing register sub-parts and floating point numbers, that in turn have or may have distinct machine size.

Because of this, and in this context, these machine features become annotated with specific tags: reg int, float, char.

With all this context:

It have been possible to reserve the most negative integer 100...000 to indicate an invalid integer.

If you didn't know the int size beforehand, you couldn't reserve a specific extremal number as guard to represent a invalid state.

I realise there may be no answer to the question why wasn't this done, but if there are reasons why this would have been a bad idea I would be interested to hear them.

Machines at that time (and now) don't reserve an entire number for "invalid register value" at hardware level, so there is no demand to represent this on B or C.

This would have provided a NaN value that could be returned from functions like atoi().

General failures in C utilize a different idiom:

int func( param , &parsed )

// compiled as

(register success) func( register param , address_to_write_if_success parsed )

$\endgroup$
4
  • 3
    $\begingroup$ The history of C and CPUs is fairly intertwined. C was designed as a high-level assembly language for the CPUs of its time, then newer CPUs were designed to run C efficiently -- including instructions to build strlen. In this sense, I guess the answer to the OP is therefore: doesn't exist on mainstream CPUs. $\endgroup$ Aug 16, 2023 at 16:51
  • $\begingroup$ @MatthieuM.: Perhaps a better example of processors designing around C is that x86's SSE/SSE2 vector and scalar FP math instructions include an instruction for FP to int conversion with truncation toward zero, for (int)my_float. Previously, compilers had to change the x87 rounding mode to truncation and then back, or use multiple instructions. For strlen, are you thinking of 8086 repne scasb? It was designed between 76 and 78, very early years for C (1972), and was aimed at Pascal explicit-length strings. repne scasb didn't get fast-strings microcode in P6, not until Sapphire Rapids $\endgroup$ Aug 17, 2023 at 16:12
  • $\begingroup$ AArch64's memory ordering is another example of designing a CPU around high-level languages. They provide release-store and acquire-load that avoid StoreLoad reordering wrt. each other but not other instructions, giving seq_cst as cheaply as possible (not much stronger than C/C++ requires). Of course the fact that they didn't initially provide weaker acquire/release (until ARMv8.3 ldapr which can StoreLoad reorder including with stlr) might have been a sign they were catering to Java more than C++11 / C11. $\endgroup$ Aug 17, 2023 at 16:14
  • 1
    $\begingroup$ The evolution of modern C is based on the assumption that the only situations where some observable deviations from precise sequential program execution might be acceptable are those where no possible behaviors would be viewed as unacceptable. Most platforms could at minimal cost offer a guarantee that a race condition on a read would have no side effect beyond yielding possibly meaningless data, but there's no standard means of indicating a compiler should process code written using "ordinary" syntax in such fashion. $\endgroup$
    – supercat
    Aug 18, 2023 at 19:44
7
$\begingroup$

Making equal numbers of positive and negative numbers is not necessarily a pleasing mathematical property. You get rid of the case that "INT_MIN is its own negation" but at a cost. Two's complement uses all the bits to nicely implement a cyclic group of order a power of two by shifting the equivalence class representatives from "unsigned" {0, ..., 2^n-1} to "signed" {-2^(n-1), ..., 2^(n-1)-1}. If one value is not a number, then you are missing one equivalence class. I suppose if you never intend to use overflow (C signed overflow is currently undefined behavior), then it doesn't matter. You could even have multiple invalid integers as long as you never step out of your "valid range".

Relevant: https://jeremykun.com/2023/07/10/twos-complement-and-group-theory

$\endgroup$
0
7
$\begingroup$

C was designed as an abstraction which covered features of hardware existing at the time. Since there were no mainstream CPUs that would produce a special NaN value as a result of a division by zero, overflows, etc., no such feature was introduced in C. Introducing a feature for which there is no widespread hardware support would instantly take C out of the "high-level assembler" league, as building even the simplest C program would require to link it against the NaN emulation library, and, more importantly, make it much slower as each integer operation would now require several assembly instructions which produce NaN values when required.

By the way, NaN is not the most useful feature in integer arithmetic which lacks most operations able to produce it, such as log, sqrt, etc. Divisions by zero and overflows would benefit from +Inf and -Inf values much more, a feature which was later implemented in the form of Saturation arithmetic.

$\endgroup$
14
  • $\begingroup$ NaN would be a useful feature for integer types, if the required maximum supported negative value for signed long long were relaxed to 0x7F00000000000000, so that any value could be forced to NaN by writing its upper byte, and if there were a recognized category of implementations where integer overflow was defined as yielding NaN. When performing multi-word arithmetic, some platforms would benefit from writing the lower portions of a result before performing computations on the upper portions, before they could know if overflow would occur or had occurred. If a high word... $\endgroup$
    – supercat
    Aug 17, 2023 at 16:14
  • $\begingroup$ ...which is the ones'-complement of the largest positive value were used as indicating NaN, however, that would greatly facilitate latching NaN behavior. $\endgroup$
    – supercat
    Aug 17, 2023 at 16:15
  • $\begingroup$ @supercat "NaN would be a useful feature for integer types" - that's what I'm disagreeing with in the second paragraph. What would you do with a NaN in your program that you couldn't achieve with writing a code which doesn't overflow in the first place (and have it tested with polyspace and -ftrapv)? Inf is useful: if you're decoding colors, you can't go brighter than pure white. But having a NaN instead means you failed to decode the colors and need to fix your program. $\endgroup$ Aug 18, 2023 at 7:40
  • $\begingroup$ Languages with defined trap-on-overflow semantics severely restrict the ability of compilers to process operations in parallel or defer operations until their results will be used (and skip them entirely if the results never end up being used). Efficiently supporting NaN behavior with integers would require hardware support (just as with floating-point), but making hardware for a parallel-add instruction which will set the upper bits to 10000000... if an overflow occurs, or if a source operand's upper bits are already 10000000 operate as fast as an ordinary "add" would not be.... $\endgroup$
    – supercat
    Aug 18, 2023 at 17:43
  • 1
    $\begingroup$ @supercat You seem to imply that it's impossible to guarantee that an algorithm is overflow-free. This is not true, there are even tools that perform formal verification w.r.t overflows. Where I work (automotive) being overflow-free is one of the requirements before you can release your SW. $\endgroup$ Aug 21, 2023 at 9:09
6
$\begingroup$

Use the R language

R has NA, which can be an integer and has the semantics of NaN. R's NA can also be a boolean (logical) and it's the result of comparisons on NA. You can check whether a value is NA using is.na.

NA + 5L # NA
5L + NA # NA
typeof(NA + 5L) # "integer"
NA > NA # NA
typeof(NA > NA) # "logical"

Internally, R's NA_INTEGER is represented by INT_MIN. Another fun fact: NA_REAL is completely separate from NaN (the "regular" floating-point NaN), and they have some odd behavior...

NaN + NA # NaN
NA + NaN # NA
typeof(5L + NaN) # "double"
is.na(NaN) # TRUE
NaN == NaN # NA

Make your own (using wrappers)

Create a class which wraps integers and reserves some bytes to represent NaN. The constructor takes a regular integer and either returns your language's equivalent of "None" or errors if given something out-of-range.

class NanInt {
    int repr;
    explicit NanInt(int value) : repr(value) {}
  public:
    static NanInt regular(int value) {
        // You could also throw a C++ exception or use std::optional
        assert(value != INT_MIN && "INT_MIN is reserved for NaN");
        return NanInt(value);
    }
    static NanInt NAN = NanInt(INT_MIN);
    bool isNan() { return repr == INT_MIN; }
    int toRegular() {
        assert(repr != INT_MIN && "can't convert, this is NaN");
        return repr;
    }
    NanInt operator+(NanInt other) {
        // In the rare case `repr + other.repr == INT_MIN`, let it be
        return repr == INT_MIN || other.repr == INT_MIN || 
            add_overflows(repr, other.repr) ? NAN : NanInt(repr + other.repr);
    }
    NanInt operator==(NanInt other) {
        // Preserve the behavior where NaN != NaN
        return repr != INT_MIN && other.repr != INT_MIN && repr == other.repr;
    }
    // ...
}

Pros

  • Possible to some extent in nearly any language: any language with classes, and even a language without classes this can be emulated using functions.
  • In languages which support wrapper classes which have the same representation as the data they wrap (Kotlin, Scala, Rust and C/C++), this is a zero-cost abstraction
  • Many languages let you overload operators and implement one or more Numeric interfaces which are used by arithmetic functions like max and Iterable.sum, so that your custom class can have the same API and be used in the same functions as regular integers
  • Some languages, like Haskell, even support custom types for integer literals (so you can write something like 4 :: NanInt).

Cons

  • Not all languages let you define zero-cost wrappers (in particular dynamic ones), and not all let you overload operators. In Java or JavaScript, your "wrapper" will have considerable overhead due to being a heap-allocated class; it must be have its own functions NanInt#add(NanInt), NanInt#equals(NanInt); lenient casting means you must be careful to use these functions and not the standard + and == operators (which will compile but produce incorrect behavior); and you must explicitly handle when your NanInt is null.
  • You have to override all the operators you plan to use, which is a lot of boilerplate. In languages without Numeric interfaces, you must also define your own functions like max and Iterable.sum.
  • Rarely does a language support checking integer literals or constructors at compile-time, so if you somehow manage to accidentally define SHOULD_BE_REGULAR = NanInt::regular(INT_MIN), it will crash at runtime.
  • Related, constructing one of your special integers has (albeit minor) runtime overhead.

Non-2^n-bit integers

Augmenting the above, there are languages which let you write integers which don't fit entirely within 32 or 64 bits, so that the extra bits can be used to represent invalid or other kinds of values. This lets you write a wrapper for a type which is guaranteed to not be Nan, which can be embedded into other types and not take up the full value.

Rust non-zero integers

Ex: NonZeroI32, NonZeroUsize (there are ones for every signed and unsigned integer type). More information in the RFC

These are special wrappers for integers which exclude 0. They are "special" in that an Option<NonZeroInt> has the same memory layout as NonZeroInt, and None represents the data 0x0.

While these wrappers only exclude 0, you can define your own wrapper which excludes a different number (like INT_MIN == 0, and every other integer below 1 is subtracted by 1). As long as you use repr(transparent) (and probably even if not), you get the same special behavior when your wrapper is used within an Option.

use std::num::NonZeroI32;
use std::cmp::Ordering;
use std::ops::Add;
use std::fmt::{Display, Formatter};

#[repr(transparent)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
struct NotNanI32(NonZeroI32);

#[repr(transparent)]
#[derive(Debug, Clone, Copy)]
struct NanI32(Option<NonNanI32>);

impl NonNanI32 {
  pub fn try_new(x: i32) -> Option<Self> {
    if (x == i32::MIN) {
      None
    } else if (x < 1) {
      Some(Self(unsafe { NonZeroI32::new_unchecked(x.unchecked_sub(1)) }))
    } else {
      Some(Self(unsafe { NonZeroI32::new_unchecked(x) }))
    }
  }

  pub fn get(self) -> i32 {
    if (self.0.get() < 0) {
      unsafe { self.0.get().unchecked_add(1) }
    } else {
      self.0.get()
    }
  }
}

impl Add for NonNanI32 {
  // Standard rust panics on overflow and underflow, so we do the same,
  // but you could also make this return Option<NonNanI32>
  type Output = Self;
  fn add(self, rhs: Self) -> Self {
    let lhs = self.0.get();
    let rhs = rhs.0.get();
    match lhs.checked_add(rhs) {
      None if lhs.saturating_add(rhs) == i32::MIN => panic!("underflow"),
      None => panic!("overflow"),
      Some(x) if x == i32::MIN => panic!("underflow"),
      Some(x) if x < 1 => {
        Self(unsafe { NonZeroI32::new_unchecked(x.unchecked_sub(1)) })
      }
      Some(x) => Self(unsafe { NonZeroI32::new_unchecked(x) })
    }
  }
}
impl Display for NonNanI32 {
  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
    write!(f, "{}", self.0)
  }
}
// ...

impl NanI32 {
  pub fn try_regular(x: i32) -> Option<Self> {
    NonNanI32::try_new(x).map(Self)
  }

  pub const NAN: Self = Self(None);

  pub fn regular(self) -> Option<NonNanI32> {
    self.0
  }

  pub fn is_nan(self) -> bool {
    self.0.is_none()
  }
}

impl Add for NanI32 {
  // Unlike the C++ version, we still panic on overflow
  type Output = Self;
  fn add(self, other: Self) -> Self {
    match (self.0, other.0) {
      (Some(lhs), Some(rhs)) => Self(Some(lhs + rhs)),
      _ => Self::NAN
    } 
  }
}
impl PartialEq for NanI32 {
  fn eq(&self, other: Self) -> bool {
    !self.is_nan() && !other.is_nan() && self.0 == other.0
  }
}
impl PartialOrd for NanI32 {
  fn partial_cmp(&self, other: Self) -> Option<Ordering> {
    match (self.0, other.0) {
      (Some(lhs), Some(rhs)) => Some(lhs.cmp(rhs)),
      _ => None
    }
  }
}
impl Display for NanI32 {
  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
    match (self.0) {
      None => write!(f, "NaN"),
      Some(regular) => write!(f, "{}", regular)
    }
  }
}
// ...

31-bit or 63-bit integers

Some languages' native integer types are 31 bits (Ruby and Smalltalk) or 63 bits (OCaml). In this case, the extra bit represents a tag to determine if the data is boxed (i.e. whether it's an integer or object).

If you write a custom language, the extra bit may represent whether the integer is NaN.

Unfortunately, most existing languages don't support custom-width integers, so a custom language is the only way to use this so far. But it may be coming in the future, most notably to Zig: see this open issue and this blog post

Use a language like JavaScript which doesn't distinguish between integers and other values, so all numbers permit NaN

This is one of the benefits of representing all numbers (including integers) within double. Whenever a function expects an integer, you can pass NaN. However, while the compiler will always let you do this, it may not work for all functions, because the developer may not have planned for NaN when writing them.

It's also one of the benefits of untyped language in general: you can simply pass a value like Float.Nan or a custom Nan with overloaded equality which has the semantics you want.

class Nan:
    def __eq__(self, other):
        return False
    def __lt__(self, other):
        return False
    # ...
    def __str__(self):
        return "Nan"
    def __add__(self, other):
        return Nan
    # ...

Nan + 3 # Nan
Nan() == Nan() # False

Aside: Why do floating-points have a defined NaN, and integers don't?

The answer for floats seems to be: convention; floating points more often need NaN; floating points already represent infinity and much larger/smaller numbers than integers; and the floating point representation has extra bits which allow for NaN. For integers: because it's rare to need a number which can be NaN but can't be represented by a floating-point; it's rarer to need such a number that also won't sometimes be too small or large to fit in a fixed-width; and if one really needs such a number, one can simply define a custom wrapper, which in many languages will be zero-cost and have nearly the same API as regular integer.

See also:

$\endgroup$
3
$\begingroup$

It always seemed self-evident to me that 2's complement makes the hardware "mechanics" of integer arithmetic very simple. Overflow and wraparound happen correctly and consistently "automatically". The carry bit is simply discarded if there is no bit left for it. Treating the last value specially breaks the entire elegance and efficiency and would be terribly expensive, either in hardware or in software.

The answer to the related question why then there are NaNs in floating point implementations is: Floating point arithmetics were always terribly expensive to begin with; NaNs didn't make a dent but are useful.

$\endgroup$
6
  • 1
    $\begingroup$ C standard was defined in times when 1's complement CPUs were still quite widespread (which is one of the reasons why C defines signed overflow as UB). Such CPUs do have a special value, the negative zero. $\endgroup$ Aug 17, 2023 at 13:08
  • $\begingroup$ @DmitryGrigoryev: On the flip side, the only intended effects of making signed overflow UB were to (1) say that code which relies upon it should be recognized as being potentially unusable on platforms that work differently in relevant cases; (2) say that implementations may issue diagnostics if signed overflow occurs, if configured for tasks where producing seemingly-valid-but-wrong results, untrapped unintentional overflows would be Not Acceptable, and avoiding "deliberate" integer overflows would be cheaper than writing code to detect all the situations where signed overflow might occur. $\endgroup$
    – supercat
    Aug 17, 2023 at 16:30
  • 1
    $\begingroup$ Yes, good point that signed 2's complement add/sub (and non-widening multiply) doesn't need special instructions, it's the same binary operation as for unsigned. So in hardware, it wouldn't just be modifying how "the signed add instruction" worked, you'd need a whole new instruction as well as modified ALU internals. $\endgroup$ Aug 17, 2023 at 17:06
  • $\begingroup$ @PeterCordes: Did ones'-complement machines have instructions that could efficiently perform unsigned math? So far as I can tell, any machine that could support unsigned long long would either be capable of handling two's-complement signed math about as efficiently as ones'-complement, or would need to have a word size of 65 bits or longer, and no ones'-complement machine has ever satisfied either criterion. $\endgroup$
    – supercat
    Aug 18, 2023 at 19:53
  • $\begingroup$ @supercat: I don't know, I haven't studied the details of any such machines. I just assumed they actually did have separate add/sub instructions for unsigned binary, but now that you mention it, it's possible they maybe only supported signed integers. So memory address ranges were limited to signed positive? $\endgroup$ Aug 18, 2023 at 21:08
3
$\begingroup$

Non-C23 Implementations are Allowed to Do That

The C17 standard says:

For signed integer types, the bits of the object representation shall be divided into three groups: value bits, padding bits, and the sign bit. There need not be any padding bits; [...]

It allows a representation to use either two’s-complement, sign-and-magnitude or one’s-complement arithmetic. Then, it says:

Which of these applies is implementation-defined, as is whether the value with sign bit 1 and all value bits zero (for the first two), or with sign bit and all value bits 1 (for ones’ complement), is a trap representation or a normal value.

A footnote also says:

Some combinations of padding bits might generate trap representations, for example, if one padding bit is a parity bit.

The Burroughs Large Systems series of mainframes is an example of a computer that was in current use in 1973, when C was written, that tagged each integer with a complex descriptor. It continued to be supported into this century (as UniSys ClearPath emode). Implementations for machines that had integer trap values historically used the native instructions for arithmetic.

This cluse the native ALU instructions for basic arithmetic. As all companies that still support those architectures do so in emulation, this has been removed in the C23 draft standard.

$\endgroup$
2
  • $\begingroup$ In 1973, the C programming language was written ;-) ; The C Programming Language, by contrast, was published in 1978, and almost certainly not in the process of being written in 1973. $\endgroup$ Aug 17, 2023 at 16:56
  • $\begingroup$ @Peter-ReinstateMonica Corrected, although the Burroughs mainframes were being manufactured in both years (and in 1989, when ANSI C was standardized). $\endgroup$
    – Davislor
    Aug 17, 2023 at 16:59
2
$\begingroup$

Many of the compromises in the design of the C Standard were predicated upon what seemed a reasonable assumption at the time: people who write compilers will treat the programmers who use them as customers. Compiler writers could be expected to uphold behavioral precedents in cases where doing so would be more useful to their customers than doing anything else, and in cases where customers would regard some other behavior as genuinely more useful, they would probably be better placed than the Committee to judge the pros and cons of that alternative.

Even if a platform uses two's-complement arithmetic, that does not necessarily imply that the platform's most efficient means of processing all operations for operands other than -INT_MAX-1 will handle that value in a manner consistent with its numerical meaning. If, for example, a platform has a "signed number divided by non-negative number" operation, processing x/-n as (-x)/n may be more efficient than having to set a flag, compute x/n, and invert the result, but using that approach when computing (-INT_MAX-1)/-2 erroneously yield -(INT_MAX/2)-1.

If code will never use a divided lower than -INT_MAX, any extra machine code to handle such dividends would make the program bigger and slower while offering zero benefit. If an implementation specifies that INT_MIN is -INT_MAX, but in all cases not involving a dividend of -INT_MAX-1 behaved using normal two's-complement arithmetic, such an implementation might thus be more useful for many tasks than one which adds extra machine code to correctly handle all corner cases associated with divisions involving the value -INT_MAX-1.

The only times a compiler writer for a two's-complement platform would have been be expected to even care about whether INT_MIN was -INT_MAX or -INT_MAX-1 would have been those where treating it as -INT_MAX-1 would be meaningfully more expensive than treating it as -INT_MAX. The notion that some compiler writers would decide that "non-portable or erroneous" really meant "non-portable and therefore erroneous", and would go out of their way to avoid behaving meaningfully in situations not addressed by the Standard would have been regarded as absurd, and nobody imagined compiler writers doing such a thing.

$\endgroup$
2
$\begingroup$

I'd challenge the usefulness of "special values" representing invalidity.

During computation

During some computation, a step that cannot produce a valid result typically means that it's useless to continue with the following steps, and that the method containing these steps can't fulfill its contract, and so the next higher method in the call stack, and so on. In well-behaved code, this propagates up to a point where some method can deal with the failure of its subordinate method call. That's exactly the behavior we get for free from exceptions, and that needs code heavily cluttered with special-case handling in a special-value based approach. So, I'd recommend to turn failed calculations into exceptions as early as possible. Alas, this isn't supported well in our current CPUs.

As stored value

When storing a value, we often need to also express the situation that this value is unavailable, unknown, not applicable, or whatever. We want a robust way to represent that, one that makes any calculation made with invalid data fail early.

Without redesign of our CPUs, just declaring some of the bit patterns as "invalid" can't achieve that. The CPU will happily do calculations with e.g. the 1000...0000 pattern, giving results that now look perfectly valid although they are nonsense.

So, you need a storage scheme that can express that, e.g. a reference that can be null, an Optional<int>, a struct with an additional bool valid field, or whatever. All this comes with a cost, as it isn't directly supported by our CPUs.

$\endgroup$
3
  • 1
    $\begingroup$ A costly aspect of exceptions is that they force an ordering on many otherwise-side-effect-free operations. If a calculation would yield NaN in case of overflow, and a compiler can show that the result of a calculation will only be used in certain circumstances, it can skip the calculation entirely any time those circumstances don't apply. If, however, overflow behavior was defined as yielding an exception, then such calculations would need to be performed without regard for whether anything would care about the results thus produced. $\endgroup$
    – supercat
    Aug 17, 2023 at 16:18
  • 1
    $\begingroup$ If allowing looser execution sequencing would let a compiler generate code that was 5% faster than would otherwise be possible, and less than 1% of runs would have any overflow, having the 99 out of 100 runs that don't overflow take an hour each, and having the one that yields overflow also take an hour but yield NaN and need to be rerun to find out where the overflow occurred, taking an extra 63 minutes, would be more efficient than having all runs take 63 minutes. $\endgroup$
    – supercat
    Aug 17, 2023 at 16:24
  • $\begingroup$ There's always a tradeoff between things like performance, language abstraction level, code readability, debuggability, robustness and so on. In my sector of software development, we are always willing to sacrifice a few percent of performance to gain some other desirable properties. But there will be other sectors having a different point of view. So, if OP is targetting a number-crunching audience, exceptions might not be the way to go. $\endgroup$ Aug 18, 2023 at 7:38

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .