Exploring Numeric Data Types in Rust and Go
Tags: rust, go, integer, float
Published: 2024-04-23 21:20:32

## Introduction

The comprehensive yet strict static type systems provided by Rust and Go, modern compiled systems programming languages that are closer to the metal than other dynamically typed scripting languages such as Python or JavaScript, allow one to deterministically declare sized variables as well as unsized, architecture dependent variables.

The privilege of a garbage collector (GC) in Go means that there is more flexibility when it comes to manipulating types and values. Rust, being a statically-typed language that has no GC but instead relies on its Ownership model to ensure memory safety, is comparatively more strict at enforcing variable assignments and type conversions when multiple types are involved. The consequence of this difference is that storing the maximum/minimum values of certain numeric type in Rust sometimes requires a variable with a type having more storage capacity (eg. storing the minimum value of the `i32` type requires an `i64`-typed variable)

In this post, we will explore the primitive numeric data types present in both languages while simultaneously drawing a comparison of the similarities and the differences.

## Integers

Integers are numeric values without a fractional part. Examples include `24`, `-2423`, `1250682`, etc...

### Sized Integers

Both Rust and Go have sized integer types: integer data types that are stored with a fixed memory value which in turn determines their ranges when it comes to the possible integer values that can be stored in them.

#### Unsigned, Sized Integers

Unsigned integers hold non-negative integer values, i.e., values that can only be 0 or positive integers. 

Given an unsigned integer type that stores `n` bits in memory, the minimum and maximum values that can be stored are `0` and `(2^n) - 1` respectively.

The various unsigned data types are:

* Unsigned, 8-bit integer `0 to 255`
  * **Rust**: `u8`
  * **Go**: `uint8`
  * **Minimum**: `0`
  * **Maximum**: `2^8 - 1 = 255`
* Unsigned, 16-bit integer `0 to 65535`
  * **Rust**: `u16`
  * **Go**: `uint16`
  * **Minimum**: `0`
  * **Maximum**: `2^16 - 1 = 65535`
* Unsigned, 32-bit integer `0 to 4294967295`
  * **Rust**: `u32`
  * **Go**: `uint32`
  * **Minimum**: `0`
  * **Maximum**: `2^32 - 1 = 4294967295`
* Unsigned, 64-bit integer `0 to 18446744073709551615`
  * **Rust**: `u64`
  * **Go**: `uint64`
  * **Minimum**: `0`
  * **Maximum**: `2^64 - 1 = 18446744073709551615`
* Unsigned, 128-bit integer `0 to 340282366920938463463374607431768211455`
  * **Rust**: `u128`
  * **Go**: no equivalent primitive type
  * **Minimum**: `0`
  * **Maximum**: `2^128 - 1 = 340282366920938463463374607431768211455`
  
#### Signed, Sized Integers

Signed integers can hold both positive and negative integer values, but effectively only have half the storage capacity of their unsigned counterparts as the allocated memory is split between the positive and negative halfs. In terms of bits of memory, this can be seen as the signed integer having one less bit of storage capacity, since the sign bit takes up one extra bit.

Given a signed integer type that stores `n` bits in memory, the minimum and maximum values that can be stored are `-2^(n - 1)` and `2^(n - 1) - 1` respectively.

The various signed data types are:

* Signed, 8-bit integer `-128 to 127`
  * **Rust**: `i8`
  * **Go**: `int8`
  * **Minimum**: `-2^7 = -128`
  * **Maximum**: `2^7 - 1 = 127`
* Signed, 16-bit integer `-32768 to 32767`
  * **Rust**: `i16`
  * **Go**: `int16`
  * **Minimum**: `-2^15 = -32768`
  * **Maximum**: `2^15 - 1 = 32767`
* Signed, 32-bit integer `-2147483648 to 2147483647`
  * **Rust**: `i32`
  * **Go**: `int32`
  * **Minimum**: `-2^31 = -2147483648`
  * **Maximum**: `2^31 - 1 = 2147483647`
* Signed, 64-bit integer `-9223372036854775808 to 9223372036854775807`
  * **Rust**: `i64`
  * **Go**: `int64`
  * **Minimum**: `-2^63 = -9223372036854775808`
  * **Maximum**: `2^63 - 1 = 9223372036854775807`
* Signed, 128-bit integer `-170141183460469231731687303715884105728 to 170141183460469231731687303715884105727`
  * **Rust**: `i128`
  * **Go**: no equivalent primitive type
  * **Minimum**: `-2^127 = -170141183460469231731687303715884105728`
  * **Maximum**: `2^127 - 1 = 170141183460469231731687303715884105727`

### Unsized Integers

The unsized integer type is used when the size of the integer is not known or not important. The actual size is determined based on the architecture of the underlying machine running the program, which is true for both Rust and Go programs. 

If the system is a 32-bit architecture, the appropriate 32-bit integer type (`u32`/`i32` in Rust, `uint32`/`int32` in Go) is used. 

If the system is a 64-bit architecture, however, the appropriate 64-bit integer type (`u64`/`i64` in Rust, `uint64`/`int64` in Go) is used.

* Unsigned, unsized integer `0 to 4294967295` or `0 to 18446744073709551615`
  * **Rust**: `usize`
  * **Go**: `uint`
  * **Minimum**: `0`
  * **Maximum**: `2^32 - 1 = 4294967295` or `2^64 - 1 = 18446744073709551615`

* Signed, unsized integer `-2147483648 to 2147483647` or `-9223372036854775808 to 9223372036854775807`
  * **Rust**: `isize`
  * **Go**: `int`
  * **Minimum**: `-2^31 = -2147483648` or `-2^63 = -9223372036854775808`
  * **Maximum**: `2^31 - 1 = 2147483647` or `2^63 - 1 = 9223372036854775807`
  
## Floating Points

Floating point numbers are used to represent real numbers, i.e., numbers with a fractional component. Examples include `3.1415926`, `500.672492`, and `-28402.75927`. All floating point types are signed by default, and there is no architecture-dependent unsized variant either.

* 32-bit floating point `-3.4028235e+38 to 3.4028235e+38`
  * **Rust**: `f32`
  * **Go**: `float32`
  * **Minimum**: `-3.4028235e+38`
  * **Smallest, Positive value**: `1.1754944e-38` in Rust, `1e-45` in Go
  * **Maximum**: `3.4028235e+38`
* 64-bit floating point `-1.7976931348623157e+308 to 1.7976931348623157e+308`
  * **Rust**: `f64`
  * **Go**: `float64`
  * **Minimum**: `-1.7976931348623157e+308`
  * **Smallest, Positive value**: `2.2250738585072014e-308` in Rust, `5e-324` in Go
  * **Maximum**: `1.7976931348623157e+308`

## Implementations

Here are Rust and Go programs that try to compute the minimum and maximum values of each numeric type described above and test them against the actual values which are declared as constants in each language's respective standard libraries.

### Rust

```rust
use ibig::{ibig, ubig, IBig, UBig};

fn main() {
    {
        println!("- unsigned, unsized integer -");
        let min: usize = 0;
        let max: usize = usize::MAX;
        println!("usize min: {min}");
        println!("usize max: {max}");
    }

    {
        println!("- unsigned, 8-bit integer -");
        // computations
        let min: u8 = 0;
        let max: u16 = 2_u16.pow(8) - 1;
        // tests
        assert_eq!(min, u8::MIN);
        assert_eq!(max, u8::MAX.into());
        // values
        println!("u8 min: {min}");
        println!("u8 max: {max}");
    }

    {
        println!("- unsigned, 16-bit integer -");
        // computations
        let min: u16 = 0;
        let max: u32 = 2_u32.pow(16) - 1;
        // tests
        assert_eq!(min, u16::MIN);
        assert_eq!(max, u16::MAX.into());
        // values
        println!("u16 min: {min}");
        println!("u16 max: {max}");
    }

    {
        println!("- unsigned, 32-bit integer -");
        // computations
        let min: u32 = 0;
        let max: u64 = 2_u64.pow(32) - 1;
        // tests
        assert_eq!(min, u32::MIN);
        assert_eq!(max, u32::MAX.into());
        // values
        println!("u32 min: {min}");
        println!("u32 max: {max}");
    }

    {
        println!("- unsigned, 64-bit integer -");
        // computations
        let min: u64 = 0;
        let max: u128 = 2_u128.pow(64) - 1;
        // tests
        assert_eq!(min, u64::MIN);
        assert_eq!(max, u64::MAX.into());
        // values
        println!("u64 min: {min}");
        println!("u64 max: {max}");
    }

    {
        println!("- unsigned, 128-bit integer -");
        // computations
        let min: u128 = 0;
        let max: UBig = ubig!(2).pow(128) - 1;
        // tests
        assert_eq!(min, u128::MIN);
        assert_eq!(max, u128::MAX.into());
        // values
        println!("u128 min: {min}");
        println!("u128 max: {max}");
    }

    println!();

    {
        println!("- signed, unsized integer -");
        let min: isize = isize::MIN;
        let max: isize = isize::MAX;
        println!("isize min: {min}");
        println!("isize max: {max}");
    }

    {
        println!("- signed, 8-bit integer -");
        // computations
        let min: i16 = -(2_i16.pow(7));
        let max: u8 = 2_u8.pow(7) - 1;
        // tests
        assert_eq!(min, i8::MIN.into());
        assert_eq!(max, i8::MAX.try_into().unwrap());
        // values
        println!("i8 min: {min}");
        println!("i8 max: {max}");
    }

    {
        println!("- signed, 16-bit integer -");
        // computations
        let min: i32 = -(2_i32.pow(15));
        let max: u16 = 2_u16.pow(15) - 1;
        // tests
        assert_eq!(min, i16::MIN.into());
        assert_eq!(max, i16::MAX.try_into().unwrap());
        // values
        println!("i16 min: {min}");
        println!("i16 max: {max}");
    }

    {
        println!("- signed, 32-bit integer -");
        // computations
        let min: i64 = -(2_i64.pow(31));
        let max: u32 = 2_u32.pow(31) - 1;
        // tests
        assert_eq!(min, i32::MIN.into());
        assert_eq!(max, i32::MAX.try_into().unwrap());
        // values
        println!("i32 min: {min}");
        println!("i32 max: {max}");
    }

    {
        println!("- signed, 64-bit integer -");
        // computations
        let min: i128 = -(2_i128.pow(63));
        let max: u128 = 2_u128.pow(63) - 1;
        // tests
        assert_eq!(min, i64::MIN.into());
        assert_eq!(max, i64::MAX.try_into().unwrap());
        // values
        println!("i64 min: {min}");
        println!("i64 max: {max}");
    }

    {
        println!("- signed, 128-bit integer -");
        // computations
        let min: IBig = -ibig!(2).pow(127);
        let max: u128 = 2_u128.pow(127) - 1;
        // tests
        assert_eq!(min, i128::MIN.into());
        assert_eq!(max, i128::MAX.try_into().unwrap());
        // values
        println!("i128 min: {min}");
        println!("i128 max: {max}");
    }

    println!();

    {
        println!("- 32-bit floating point -");
        let min: f32 = f32::MIN;
        let smallest: f32 = f32::MIN_POSITIVE;
        let max: f32 = f32::MAX;
        println!("f32 min: {min}");
        println!("f32 smallest: {smallest}");
        println!("f32 max: {max}");
    }

    {
        println!("- 64-bit floating point -");
        let min: f64 = f64::MIN;
        let smallest: f64 = f64::MIN_POSITIVE;
        let max: f64 = f64::MAX;
        println!("f64 min: {min}");
        println!("f64 smallest: {smallest}");
        println!("f64 max: {max}");
    }
}
```

Running this via `cargo run` produces:

```
- unsigned, unsized integer -
usize min: 0
usize max: 18446744073709551615
- unsigned, 8-bit integer -
u8 min: 0
u8 max: 255
- unsigned, 16-bit integer -
u16 min: 0
u16 max: 65535
- unsigned, 32-bit integer -
u32 min: 0
u32 max: 4294967295
- unsigned, 64-bit integer -
u64 min: 0
u64 max: 18446744073709551615
- unsigned, 128-bit integer -
u128 min: 0
u128 max: 340282366920938463463374607431768211455

- signed, unsized integer -
isize min: -9223372036854775808
isize max: 9223372036854775807
- signed, 8-bit integer -
i8 min: -128
i8 max: 127
- signed, 16-bit integer -
i16 min: -32768
i16 max: 32767
- signed, 32-bit integer -
i32 min: -2147483648
i32 max: 2147483647
- signed, 64-bit integer -
i64 min: -9223372036854775808
i64 max: 9223372036854775807
- signed, 128-bit integer -
i128 min: -170141183460469231731687303715884105728
i128 max: 170141183460469231731687303715884105727

- 32-bit floating point -
f32 min: -340282350000000000000000000000000000000
f32 smallest: 0.000000000000000000000000000000000000011754944
f32 max: 340282350000000000000000000000000000000
- 64-bit floating point -
f64 min: -179769313486231570000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
f64 smallest: 0.000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000022250738585072014
f64 max: 179769313486231570000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
```

Note that we make use of an external crate [`ibig`](https://crates.io/crates/ibig) to help store and manage big integer computations, especially when trying to compute the minimum and maximum values of the largest data types of each kind of signed and unsigned integer. Make sure to add this dependency to your `Cargo.toml` so that Rust can pull the crate and its dependencies during `cargo run`

```toml
[dependencies]
ibig = "0.3.6"
```

### Go

```go
package main

import (
	"fmt"
	"math"
	"testing"
	
	"gotest.tools/v3/assert"
)

func main() {
	var t = &testing.T{}
	
	func() {
		fmt.Println("- unsigned, unsized integer -")
		var min uint = 0
		var max uint = math.MaxUint
		fmt.Printf("uint min: %d\n", min)
		fmt.Printf("uint max: %d\n", max)
	}()
	
	func() {
		fmt.Println("- unsigned, 8-bit integer -")
		// computations
		var min uint8 = 0
		var max uint8 = 1<<8 - 1
		// tests
		assert.Assert(t, min == 0)
		assert.Assert(t, max == math.MaxUint8)
		// values
		fmt.Printf("uint8 min: %d\n", min)
		fmt.Printf("uint8 max: %d\n", max)
	}()

	func() {
		fmt.Println("- unsigned, 16-bit integer -")
		// computations
		var min uint16 = 0
		var max uint16 = 1<<16 - 1
		// tests
		assert.Assert(t, min == 0)
		assert.Assert(t, max == math.MaxUint16)
		// values
		fmt.Printf("uint16 min: %d\n", min)
		fmt.Printf("uint16 max: %d\n", max)
	}()

	func() {
		fmt.Println("- unsigned, 32-bit integer -")
		// computations
		var min uint32 = 0
		var max uint32 = 1<<32 - 1
		// tests
		assert.Assert(t, min == 0)
		assert.Assert(t, max == math.MaxUint32)
		// values
		fmt.Printf("uint32 min: %d\n", min)
		fmt.Printf("uint32 max: %d\n", max)
	}()

	func() {
		fmt.Println("- unsigned, 64-bit integer -")
		// computations
		var min uint64 = 0
		var max uint64 = 1<<64 - 1
		// tests
		assert.Assert(t, min == 0)
		assert.Assert(t, max == math.MaxUint64)
		// values
		fmt.Printf("uint64 min: %d\n", min)
		fmt.Printf("uint64 max: %d\n", max)
	}()
	
	fmt.Println();
	
	func() {
		fmt.Println("- signed, unsized integer -");
		var min int = math.MinInt
		var max int = math.MaxInt
		fmt.Printf("int min: %d\n", min)
		fmt.Printf("int max: %d\n", max)
	}()
	
	func() {
		fmt.Println("- signed, 8-bit integer -")
		// computations
		var min int8 = -(1 << 7)
		var max int8 = (1 << 7) - 1
		// tests
		assert.Assert(t, min == math.MinInt8)
		assert.Assert(t, max == math.MaxInt8)
		// values
		fmt.Printf("int8 min: %d\n", min)
		fmt.Printf("int8 max: %d\n", max)
	}()
	
	func() {
		fmt.Println("- signed, 16-bit integer -")
		// computations
		var min int16 = -(1 << 15)
		var max int16 = (1 << 15) - 1
		// tests
		assert.Assert(t, min == math.MinInt16)
		assert.Assert(t, max == math.MaxInt16)
		// values
		fmt.Printf("int16 min: %d\n", min)
		fmt.Printf("int16 max: %d\n", max)
	}()
	
	func() {
		fmt.Println("- signed, 32-bit integer -")
		// computations
		var min int32 = -(1 << 31)
		var max int32 = (1 << 31) - 1
		// tests
		assert.Assert(t, min == math.MinInt32)
		assert.Assert(t, max == math.MaxInt32)
		// values
		fmt.Printf("int32 min: %d\n", min)
		fmt.Printf("int32 max: %d\n", max)
	}()
	
	func() {
		fmt.Println("- signed, 64-bit integer -")
		// computations
		var min int64 = -(1 << 63)
		var max int64 = (1 << 63) - 1
		// tests
		assert.Assert(t, min == math.MinInt64)
		assert.Assert(t, max == math.MaxInt64)
		// values
		fmt.Printf("int64 min: %d\n", min)
		fmt.Printf("int64 max: %d\n", max)
	}()
	
	fmt.Println();
	
	func() {
		fmt.Println("- 32-bit floating point -")
		var smallest float32 = math.SmallestNonzeroFloat32
		var max float32 = math.MaxFloat32
		fmt.Printf("float32 min: %g\n", -max)
		fmt.Printf("float32 smallest: %g\n", smallest)
		fmt.Printf("float32 max: %g\n", max)
	}()
	
	func() {
		fmt.Println("- 64-bit floating point -")
		var smallest float64 = math.SmallestNonzeroFloat64
		var max float64 = math.MaxFloat64
		fmt.Printf("float64 min: %g\n", -max)
		fmt.Printf("float64 smallest: %g\n", smallest)
		fmt.Printf("float64 max: %g\n", max)
	}()
	
}
```
Running this via `go run` outputs

```
- unsigned, unsized integer -
uint min: 0
uint max: 18446744073709551615
- unsigned, 8-bit integer -
uint8 min: 0
uint8 max: 255
- unsigned, 16-bit integer -
uint16 min: 0
uint16 max: 65535
- unsigned, 32-bit integer -
uint32 min: 0
uint32 max: 4294967295
- unsigned, 64-bit integer -
uint64 min: 0
uint64 max: 18446744073709551615

- signed, unsized integer -
int min: -9223372036854775808
int max: 9223372036854775807
- signed, 8-bit integer -
int8 min: -128
int8 max: 127
- signed, 16-bit integer -
int16 min: -32768
int16 max: 32767
- signed, 32-bit integer -
int32 min: -2147483648
int32 max: 2147483647
- signed, 64-bit integer -
int64 min: -9223372036854775808
int64 max: 9223372036854775807

- 32-bit floating point -
float32 min: -3.4028235e+38
float32 smallest: 1e-45
float32 max: 3.4028235e+38
- 64-bit floating point -
float64 min: -1.7976931348623157e+308
float64 smallest: 5e-324
float64 max: 1.7976931348623157e+308
```

Go does not have a primitive `assert` statement for asserting expressions like in Rust, but the third party `gotest.tools/v3/assert` module implements similar behaviour by augmenting the default `testing` module provided in Go's standard library. Remember to hit `go mod tidy` to pull this dependency before running this program.

Go also does not have a `block` construct like in Rust to declare arbitrary scopes in a program, but similar functionality can be achieved in Go by using Immediately Invoked Function Expressions (IIFEs) as seen above.

## A Useful Note About Type Inference

Both Go and Rust have type inference capabilities, which allow them to infer the type of a variable when it is not explicitly annotated. Go infers all integer valued variables to be of the unsized type `int`, which can correspond to `int32` or `int64` depending on architecture, while Rust infers all such variables to be of type `i32`. On the contrary, all floating point valued variables in Go as well as Rust are inferred to be of type `float64`/`f64` respectively. The Rust Book justifies this choice over the lower precision but lesser memory `f32` type as follows

> The default type is f64 because on modern CPUs, it’s roughly the same speed as f32 but is capable of more precision.

-- _Data Types (Chapter 3, Section 3.2)_

## Afterthoughts

This post was mainly for personal reference and exploration because I tend to keep forgetting the intricacies of numeric data types in Rust and Go, especially the maximum and minimum values of each data type. A lack of awareness of these ranges can cause overflow errors in both languages, something the Rust compiler helpfully points out when running in debug mode.

I hope this helps others as well who want a quick reference to numeric data types in Rust and Go and how to compute their maximum and minimum values in both languages. Feel free to leave comments suggesting additions or improvements that you feel are relevant to this discussion.