Curriculum / Part I - Foundations

02Types, Bits and Representation

What the machine actually stores when you write int, double or char, and the conversion rules that quietly destroy correctness before you ever reach the hot path.

Part I - Foundationsbeginner30 min
integersfloating-pointundefined-behaviourbits

C++ lets you name a type and mostly forget what it is made of. That works until a price comes off the wire in the wrong byte order, or a loop counter wraps below zero and runs for four billion iterations, or a compiler deletes a bounds check you were relying on because you gave it permission without knowing. Representation is not trivia here. It is the layer where correctness and latency are decided together.

The types you get, and the sizes you do not#

C++ guarantees far less about its fundamental types than people assume. The standard fixes a minimum range and an ordering, not a size:

TypeGuaranteed at leastTypical on x86-64 Linux
char1 byte, and sizeof(char) == 1 always1 byte, signed
short16 bits2 bytes
int16 bits4 bytes
long32 bits8 bytes
long long64 bits8 bytes
bool—1 byte

long is 8 bytes on Linux and macOS and 4 bytes on 64-bit Windows. That single fact has corrupted more shared-memory layouts than any other in the language. So on anything that crosses a boundary you write the width down:

types.hpp
cpp
#include <cstdint>

using Ticks = std::int64_t;    // price, in integral exchange ticks
using Qty   = std::uint32_t;   // shares or lots, never negative
using SeqNo = std::uint64_t;   // exchange sequence number

<cstdint> gives you std::int8_t through std::int64_t and their unsigned partners. These are exact-width and, on any platform you will deploy to, they exist. There are also std::int_fast32_t (at least 32 bits, whichever is quickest) and std::int_least32_t. Ignore both: on a hot path you care about layout, and layout means exact width.

sizeof yields a size in bytes as a std::size_t. alignof yields the address multiple the type must sit on. Both are compile-time constants, so you can assert on them:

cpp
#include <cstdint>
#include <cstddef>

struct Quote {
    std::int64_t  price_ticks;   // offset 0
    std::uint32_t qty;           // offset 8
    std::uint16_t venue_id;      // offset 12
    // 2 bytes of tail padding
};

static_assert(sizeof(Quote) == 16);
static_assert(alignof(Quote) == 8);   // driven by the widest member

A struct is aligned to its most demanding member, and its size is rounded up to a multiple of that alignment so arrays of it stay aligned. Reorder those three members and you can get 24 bytes instead of 16. Lesson 21 makes a discipline out of this; for now just know that sizeof is a fact you can check rather than a fact you assume.

Signed, unsigned, and the conversions that bite#

Every arithmetic operation on a type narrower than int first promotes both operands to int. This is the integral promotion, it is silent, and it is not always harmless:

cpp
#include <cstdint>

std::uint32_t notional_bad(std::uint16_t px, std::uint16_t qty) {
    return px * qty;             // both promote to int, multiply is int
}

With px == qty == 60000, the product is 3,600,000,000, which exceeds the maximum int of 2,147,483,647. That is signed overflow, which is undefined behaviour, in a function that looks like it only deals in unsigned values. The fix is to widen deliberately: return static_cast<std::uint32_t>(px) * qty;.

When one operand is signed and the other unsigned and both are at least as wide as int, the signed one converts to unsigned. This produces the single most common bug in beginner C++:

cpp
#include <cstdint>
#include <vector>

bool valid_level(const std::vector<std::int64_t>& book, int depth) {
    return depth < book.size();          // WRONG
}

book.size() is a std::size_t, which is unsigned 64-bit. Pass depth == -1 and it converts to 18,446,744,073,709,551,615, so the comparison is true and you index off the front of the book. GCC and Clang will tell you with -Wsign-compare, which is included in -Wall. Turn warnings into errors and this class of bug stops existing. When you genuinely need to compare across signedness, C++20 gives you the correct primitives:

cpp
#include <utility>          // std::cmp_less
#include <vector>

bool valid_level(const std::vector<std::int64_t>& book, int depth) {
    return depth >= 0 && std::cmp_less(depth, book.size());
}

Overflow: undefined versus wrapping#

Unsigned overflow is defined: arithmetic is modulo 2^N, and wraparound is a guarantee you may rely on. Signed overflow is undefined behaviour. The compiler is entitled to assume it never happens, and it uses that assumption aggressively.

cpp
bool will_grow(int x)          { return x + 1 > x; }
bool will_grow(unsigned int x) { return x + 1 > x; }
g++ -O2, x86-64
asm
will_grow(int):
        mov     eax, 1              ; assumed always true
        ret
will_grow(unsigned int):
        cmp     edi, -1             ; must actually check for wrap
        setne   al
        ret

That is not the compiler being clever at your expense; it is the same assumption that lets it promote a 32-bit loop induction variable to a 64-bit register without emitting a wrap check on every iteration, which is worth real cycles in a tight loop. The cost is that a signed overflow anywhere in your program licenses arbitrary behaviour everywhere.

C++20 finally mandated two's complement for signed integers, so -1 is guaranteed to be all bits set and std::int32_t is guaranteed to span -2,147,483,648 to 2,147,483,647. Note carefully what did not change: signed overflow is still undefined. Representation was fixed; the arithmetic contract was not. Build your debug and test binaries with -fsanitize=undefined and you will be told at run time the first time you cross the line.

Floating point, and why a price is an integer#

An IEEE-754 double is 64 bits: 1 sign, 11 exponent, 52 stored mantissa bits giving 53 bits of precision. It represents a number as a binary fraction scaled by a power of two. One-tenth is not such a number, any more than one-third is a finite decimal.

cpp
#include <cstdio>

int main() {
    double a = 0.1, b = 0.2;
    std::printf("%.17g\n", a + b);   // 0.30000000000000004
    std::printf("%d\n", a + b == 0.3);  // 0
}

Three separate problems follow from this, and only the first is widely known.

Representation error. A price of 100.10 is not 100.10. Accumulate a few thousand fills and your position value disagrees with the exchange's by a fraction of a tick, which the reconciliation process will find at 6pm.

Non-associativity. (a + b) + c is not a + (b + c) in floating point, so the compiler is forbidden from reordering a summation loop. That forbids vectorising it, which costs you a factor of four or eight on a reduction. -ffast-math lifts the restriction by declaring you do not care about the exact result, which on a risk calculation is a statement you should be very reluctant to sign.

Latency. Floating point division is not pipelined the way multiplication is.

OperationLatency (cycles)Throughput (per cycle)
64-bit integer imul31
addsd / mulsd42
divsd13 to 20, microarchitecture dependent1 per 4 to 6 cycles

Those figures are for recent Intel and AMD desktop and server parts; check Agner Fog's tables or llvm-mca for your exact target rather than trusting the range.

Raw bytes: std::byte, endianness and type punning#

char is three types wearing a coat: char, signed char and unsigned char are distinct, and plain char has implementation-defined signedness (signed on x86 Linux, unsigned on ARM Linux). char means "a character", not "a small number". For raw storage use std::byte, which is a scoped enumeration over unsigned char that supports only bitwise operations, so you cannot accidentally do arithmetic on it.

x86-64 is little-endian: the least significant byte sits at the lowest address. Most exchange protocols descended from network byte order are big-endian, NASDAQ ITCH among them, while CME's Simple Binary Encoding is little-endian. You must know which, and C++23 gives you the swap directly:

decode.hpp
cpp
#include <bit>
#include <cstddef>
#include <cstdint>
#include <cstring>

// Reads a big-endian 64-bit field from an unaligned wire buffer.
std::int64_t load_be64(const std::byte* p) noexcept {
    std::uint64_t raw;
    std::memcpy(&raw, p, sizeof raw);
    if constexpr (std::endian::native == std::endian::little) {
        raw = std::byteswap(raw);          // one BSWAP instruction
    }
    return static_cast<std::int64_t>(raw);
}

The memcpy is not a copy. At -O2 both GCC and Clang turn a fixed-size memcpy into a single unaligned load, and std::byteswap into a single bswap. What the memcpy buys you is legality: the strict aliasing rule says you may not read an object through a pointer to an unrelated type, so *reinterpret_cast<const std::uint64_t*>(p) is undefined behaviour even though it usually appears to work. memcpy and std::bit_cast are the two sanctioned escape hatches.

cpp
#include <bit>
#include <cstdint>

// Same bits, different type. Both operands must be trivially copyable
// and the same size. constexpr-capable, unlike memcpy.
std::uint64_t bits_of(double d) noexcept { return std::bit_cast<std::uint64_t>(d); }

Bit operations that are one instruction#

<bit> exposes the CPU's bit-manipulation instructions portably, with a correct fallback where the hardware lacks them. Each of these compiles to one instruction given a suitable -march:

cpp
#include <bit>
#include <cstdint>

static_assert(std::popcount(0b1011u) == 3);          // POPCNT
static_assert(std::countl_zero(std::uint32_t{1}) == 31);  // LZCNT
static_assert(std::bit_width(std::uint32_t{4096}) == 13); // 1 + floor(log2 x)
static_assert(std::has_single_bit(4096u));                // power of two?
static_assert(std::bit_ceil(4000u) == 4096u);

The trading use is immediate. A ring buffer sized to a power of two lets you replace an index modulo, a 20-plus cycle division, with a single and against capacity - 1; you use std::bit_ceil to round the requested capacity up and std::has_single_bit to assert the invariant. A bitset of active price levels lets std::countr_zero find the best level in one instruction rather than a loop.

Takeaways#