comptime or !comptime?

Photo by Bob Coyne on Unsplash

Maybe this is already obvious for some, but noting it for myself (and others if they happen to stumble upon this note or this question, or stumble upon this note and then get this question).

Types as values exist only at compile time.

Meaning? If you want your own custom struct describing an entity, that becomes a type definition and handling types cannot be left to runtime.

Let's build an example.

// enum of departments
const Dept = enum {
petroleum,
@"roads & highways",
health,
defence
};

const Minister = struct {
name: []const u8, // always needs a "Shri/ Honble"
authenticity: bool = false, // it's still changeable for an instance!
departments: []const Dept, // infinite acumen
generationalWealth: u128 // it will be quite large
};

Here "honble" Minister is a type, but alas, we have kept the authenticity parameter open to instantiation during runtime.

const ministerAlpha = Minister{
.name = "Shri Honble Fuel Maestro",
.authenticity = true,
.departments = &.{ .petroleum, .@"roads & highways" },
.generationalWealth = 11000000000
};

If I can be assured authenticity is questionable even before declaring a minister, why not hardcode it? (not as in default values)

We can use comptime for this

//a "real" minister struct
const RealMinister = struct {
pub const authenticity = false;

name: []const u8,
...
};

This will not get any space in the binary's runtime memory for the field (why waste resource when we already know the value?).

If you check size

std.debug.print("Size of Minister: {} bytes\n", .{@sizeOf(Minister)});
std.debug.print("Size of Real Minister: {} bytes\n", .{@sizeOf(RealMinister)});

size of Minister: 64 bytes
size of Real Minister: 48 bytes

Now consider if we have departments being checked over and need conditional actions.

Let's create a cabinet mapping, we give a dept name & want to get the associated metrics.

const PetroleumMetric = struct {
tariff_price_enabled: bool = true,
forced_e20_fuel: bool = true
};

const RoadsMetric = struct {
space_tech: bool = true,
longevity_in_days: f32 = 60.2
};

const FinanceMetric = struct {
total_budget: u64 = 5000000,
campaign_budget: u64 = 70000000
};
const HealthMetric = struct {
active_hospitals: u32 = 20000,
reqd_hospitals: u32 = 100000
};
const DefenceMetric = struct {
security_clearance_level: u8 = 3,
security_hardening_coverage: f32 = 20.04
};

const CabinetSystem = struct {
petroleum: PetroleumMetric = .{},
@"roads & highways": RoadsMetric = .{},
finance: FinanceMetric = .{},
health: HealthMetric = .{},
defence: DefenceMetric = .{}
};

// let's get a runtime string (which will be comparable to our enum variants)
const input_dept: []const u8 = "roads & highways";

// convert to a enum to do 1-1 comparison
const runtime_val_as_enum = std.meta.stringToEnum(Dept, input_dept) orelse raise error.SomeError;

// cabinet instance, gets default values in it's fields
const cabinet = CabinetSystem{}

Now let's do a comptime switch case to match any of enum variants and show the associated metric, via inline else (for the switch) and inline for (for iterating the struct field)

switch (runtime_val_as_enum) {
inline else => |dep| {
// make instance field accessible via it's comptime stored name
// example, cabinet.finance, but this is comptime
const dept_metric = @field(cabinet, @tagName(dep));

// get the generic struct fields, from compiler
const fields = std.meta.fields(@TypeOf(dept_metric));

// loop for fields and print
inline for (fields) |metric| {
std.debug.print("field name is {s}, dept is [{s}], field value is {any}\n",
.{metric.name, @tagName(dep), @field(dept_metric, metric.name)}
);
}
}
}

This switch statement actually signals compiler to resolve the logic branches upfront.

Get me all values possible for runtime_val_as_enum (which are the enum values). Resolve automatically like

switch (val) {
.defence => {..}
.health => {..}
...

for each branch, get the struct field as an accessible type, and print out, the entire sequence being like a blueprint.

And we get

field name is space_tech, dept is [roads & highways], field value is true
field name is longevity_in_days, dept is [roads & highways], field value is 60.2

Note that each cabinet metric may contain different field types (f32, u64 etc), but here compiler handles it just fine.

Whenever runtime passes a string (that is supposed to match any of the enum) compiler has already laid out the branches, no runtime computation/wastage for this, it routes to matching branch immediately.

The entire unfolding of for/switch branches is taken care of, so we didn't need to explictly define actions on each value. (less boilerplate).

How does it differ exactly than just accepting a runtime value and matching to print?

You can do the same without inline for/else too, just get the input string and do the match/resolution runtime.

But imagine each of these depts having different lookup data/computation logic within (rather than this simple print).

If we decide comptime resolution, none of that needs to be done at runtime, which can give significant savings on CPU cycles.

So should we prefer this?

IMO, it helps well with exhaustive handling of tagged unions, enums (take care to choose low cardinality cases which need exhaustive coverage nonetheless).

It can also be considered if you are generating code or types on the fly (similar to macros in other languages), or doing SerDe (go parse/handle fields in JSON etc).

Comptime however should be avoided when there are a lot of branches/loops to be unrolled, or needing heavy computation which will cause compiler to take more RAM and slow down while building the binary.