Learn systems programming as a web dev - Part #1: The humble integer vol1

Learn systems programming as a web dev

Talking to students and friends I think one of the most common things I see is people wanting to break free from the very narrow conception that they have of programming, but them not even knowing where to start. This is my intention with this series of articles, to not only provide a concrete path for those looking to expand to new fields, but also change people’s perspective on programming, once you think like a systems engineer, you can’t go back, these learnings will carry on to your next projects no matter the tech stack.

Choosing a language

There are many roads we can take from here, but it’s really important to emphasize something here, a language is nothing more than a tool, this is the same reason why the concepts you’ll see throughout this series is widely applicable to other languages.
Having said that, I’ll choose C for the concrete examples on this series, you can follow along in any of the other languages I’ll list below and I will do my best to provide equivalent function names and conventions as we go forward.

Setting up

(If you just want to get to the code you can download this installation script and it’ll walk you through an easy setup designed by me!)

This will look different depending on the operating system you are running.

All operating systems support the clang compiler, so that’s what we’ll be using. The second most supported compiler is GCC, so all examples will be geared towards them.

Here’s a more concrete table of tools that you might find useful throughout this journey.

OSCompilersDebugger
WindowsMSVC (Installed with Visual Studio)RADDebugger
Windows (WSL)GCCGDB
LinuxGCCGDB
MacOSAppleClangLLDB

Part 1. The Humble Integer

Integer types

If you come from a language like JS or Python you may only be familiar with the default number or inttypes to represent integers, but in other languages and tech stacks we have different types for integers which change in bit size and their binary interpretation (signed/unsigned).

C provides fixed-width integer types through <stdint.h>. Here’s a reference of all of them alongside their equivalents in the other languages of this series (C++ uses the same names via <cstdint>):

C (stdint.h)MinMaxC++ (cstdint)OdinZig
int8_t-128127int8_ti8i8
int16_t-32,76832,767int16_ti16i16
int32_t-2,147,483,6482,147,483,647int32_ti32i32
int64_t-9,223,372,036,854,775,8089,223,372,036,854,775,807int64_ti64i64
uint8_t0255uint8_tu8u8
uint16_t065,535uint16_tu16u16
uint32_t04,294,967,295uint32_tu32u32
uint64_t018,446,744,073,709,551,615uint64_tu64u64

These numbers are not arbitrary, they cover the range of possible decimal integers you can represent using their specified amount of bytes, let’s do the math ourselves so we fully understand it.

Take the uint8_t.

As its name states, we have 8 bits to work with, let’s bring up our trusty binary table:

6432168421
0000000

If we decide to turn on any one of those bytes, we add the value above the slot.

6432168421
0000001

Becomes => 1

6432168421
0000101

Becomes => 5

The maximum number we can represent if we turn on ALL the slots:

6432168421
1111111

64 + 32 + 16 + 8 + 4 + 2 + 1 = 255

This, of course only covers positive numbers, that’s what the u in uint8_t stands for: unsigned, if we want to represent negative numbers with the same amount of bytes, we need to apply something called Two’s complement, we won’t really get into that right now, but TLDR; it cuts the representable numbers in half (and one extra position for +0 and -0 respectively).

Number limits

For any number of bits (N) in an integer variable, we can quickly find out the limits by doing MAX_NUM = 2^N if the type is unsigned, and MAX_NUM = [(2^N) / 2] - 1 if it’s signed

Optimizing with integers (understand the problem to understand the data)

But… Why is this information useful to us???

Well, knowing the ins and outs of integers and how they are represented can be very beneficial in low level programming, most notably it lets us do memory layout optimizations if we know the specific usage of those number, let’s quickly look at an example:

You are tasked with building a game with A LOT of enemies, we’re talking thousands on screen at any given point, the enemy struct currently looks like this:

typedef struct Enemy {
	Vector3 position;
	int damage;
	int health;
	int maxHealth;
	int armor;
	int level;
} Enemy;

All the fields are self explanatory, as a small note, a Vector3 is 12 bytes in size, so, knowing this, let’s calculate the size of our enemy struct, by default, in most modern C compilers, a raw int is 4 bytes (essentially an int32_t), so

typedef struct Enemy {
	Vector3 position; // 12
	int damage; // 12 + 4 = 16
	int health; // 16 + 4 = 20
	int maxHealth; // 20 + 4 = 24
	int armor; // 24 + 4 = 28
	int level; // 28 + 4 = 32
} Enemy;

So, our enemy is 32 bytes in memory, and remember how I mentioned we’ll have thousands of them on-screen at any given point?

Let’s allocate that in an array:

#define MAX_ON_SCREEN_ENEMIES 2000
Enemy* enemies = malloc(sizeof(Enemy) * MAX_ON_SCREEN_ENEMIES); // 32 * 2000 = 64000 = 62.5 kB

The enemies array memory footprint is 62.5kB, which might not sound like much, but things do add up with time, and if the enemies become more complex throughout development, this will get worse with time, so, how do we optimize things here? By understanding the problem, and modeling the data after it. By not knowing anything specific about our game we can’t really make decisions about what to do to reduce memory usage, but, our imaginary game designer has stipulated that

… It basically plateus at about 180 armor, reducing the damage by about 97%, that no longer falls in the ranged of a signed 8 bit integer, but there’s also no need to increase the bit size of our integer, just to get rid of the sign to go from a maximum of 127 to 255, which is more than enough to cover our most extreme case of super tanky enemies, so we turn this into a uint8_t.

With this, our struct now looks like this:

typedef struct Enemy {
	Vector3 position; // 12
	int8_t damage; // 12 + 1 = 13
	int8_t health; // 13 + 1 = 14
	int8_t maxHealth; // 14 + 1 = 15
	uint8_t armor; // 15 + 1 = 16
	int8_t level; // 16 + 1 = 17
} Enemy;

Now, due to alignment rules, the compiler will insert extra padding bytes to make sure the struct’s size falls to the next nearest power of 2, in this case it’s 20, even then, we reduced our memory footprint significantly; if we spawn the same 2000 enemies, we have 2000 * 20 = 40000 ~= 39.06kB, that’s a reduction of about 37% of memory usage, just by knowing what our data will be used for and optimizing for that scenario.

This optimization encapsulates pretty well the mindset behind systems programming, data oriented approches, “To understand the problem is to understand the data, if you don’t understand the data, you can’t solve the problem”.

Encoding flags

Now, let’s look at another example that comes up in various types of applications, a role based access system, and we will check how the humble integer can help us make things simpler. I’ve seen approaches similar to this in many codebases:

Data from a DTO passed to a Database

"user": {
	"name": "Jon B",
	"age": 25,
	"admin": true,
	"vendor": false,
	"manager": true,
	"storeOwner": false,
	"districtManager": false,
	"executive": false,
	"stakeholder": false
}

At a glance this can look like a roboust way of handling permissions, after all, logic is gated behind these flags, it’s not bad to be honest, but it is really, REALLY wasteful in many ways, it’s not only what’s in RAM when we create these users, but also in the response/request data over the network, and most importantly, in database itself, every record will have these fields which, at scale can really add up…

Let’s assume our user is represented by this struct.

struct User {
	char name[64];
	uint32_t age;
	bool isAdmin;
	bool isVendor;
	bool isManager;
	bool isStoreOwner;
	bool isDistrictManager;
	bool isExecutive;
	bool isStakeHolder;
}

This comes to a size of 76 bytes per user, let’s just talk about the user data itself, obviously any Database implementation will most likely add metadata on top of it causing way more overhead, assuming our DB holds the data for 100,000 users, that’s about 7.24 MB of data in our hard drive.

Now, let us introduce our next optimization:

BitFlags

Bitflags are simple, yet powerful, they rely on the internal binary representation of numbers we just saw in this section, if we mentally replace each slot in the binary table for a logic role of our app:

StakeHolderExecutiveDistrictManagerStoreOwnerManagerVendorAdmin
0000000