How much OOP is too much OOP: A question around constructors

Curious how others feel about using constructors in struct definitions. This is very OOP to me and from my understanding Zig is a very functional language. As an example I have provided below a snippet of code that shows exactly what im describing. I am creating a new view from data within the parent object. Is this idiomatic Zig? Coming from Golang I understand the balance of using some OOP ideas but only what makes sense. I was curious what others thought about the use of constructors over using an object agnostic function.

I did notice we use .init() on objects quite a lot, so figured my example below might just be okay?


pub const Proc = struct {
pid: pid_t,
start_time_ns: i128 = 0,
s_name: [256:0]u8,
path: [4096]u8,
mem_rss: u64 = 0,
total_user: u64 = 0,
total_system: u64 = 0,

pub fn identity(self: *const Proc) model.ProcIdentity {
    return .{ .pid = self.pid, .start_time_ns = @intCast(self.start_time_ns) };
}

}

You’re correct that creating constructor methods religiously for every type is not idiomatic in Zig.

If a struct’s fields all have default field values, then initializing with .{} is fine.

Otherwise, a method for initializing is needed to avoid redundancy. An init method on the struct, or a method on another type like you’ve done (perhaps a parent object has a method for creating its child) is fine.

1 Like

Overall, Zig tends to favor an imperative style, does not favor object-oriented programming, and certainly does not encourage functional programming. Actually, I don’t think object-oriented programming is a concept opposed to functional programming, even though they are often presented as opposites.

The term OOP carries too many concepts. In my view, OOP is like a mixed bag of features. When different people mention this term, they are talking about different things. Some talk about encapsulation when discussing OOP, some talk about ‘namespacing of types,’ some about ‘infix notation in function calls,’ and others about inheritance-based polymorphism.

I don’t think Zig particularly cares about whether it needs to strictly guard against OOP. Features that many people like, such as ‘types as namespaces’ and ‘infix syntax sugar for functions,’ Zig embraces them. As for the controversial inheritance-based polymorphism, Zig neither embraces nor rejects it; in fact, it allows you to design any style of interface scheme, including inheritance-based ones.

10 Likes

OOP is a very loaded term. I don’t personally think struct initialization has anything to do with OOP. It’s more of language level features that allow inheritance and such.

Since the introduction of decl literals, default field initialization has become more frowned upon. The idiomatic approach is to have .empty .init .some_variant instead as decl literal or as a function that decides how the struct is initialized.

5 Likes

The remaining problem is that only default-field-init in the struct declaration allows to define defaults which can be overridden, I hope Zig gets a solution that works with the new .init idiom.

E.g. I can’t do this with .init:

// define default values in struct declaration:
const Bla = struct { x: u32 = 1, y: u32 = 2, z: u32 = 3 };

// create a 'Bla' but only override .y with a non-default value: 
const bla: Bla = .{ .y = 23 };

…e.g. the alternative pattern of overriding the defaults after initialization comes with all sorts of downsides (bla cannot be a const, and readability suffers dramatically):

const bla: Bla = .init;
bla.y = 23; // error: cannot assign to constant

Pretty much all my APIs rely on the ‘sane - but overridable - defaults’, and the move to the .init pattern doesn’t really make me happy (as long as defaults in the struct declaration work I don’t care much of course)… I would like to see more syntax sugar for flexible struct initialization to be sure though.

2 Likes

You could use functions:

pub fn scoped(x: ..., y: ...) @This() {
   ...
}

pub fn initWithOptions(options: Options) @This() {
  ...
}

If you require full structs with defaults, then defaults values obviously is the way to go still.

2 Likes

I’d rather have syntax tbh, maybe smth like:

const bla: Bla = .defaults ++ .{ .y = 23 };

It gets tricky quickly because it also needs to work for nested structs and arrays (ideally - currently arrays also can’t be partially initialized with Zig’s designated init syntax).

But I’m having deja-vue, I think we discussed those options already elsewhere :wink:

2 Likes

This work well if each field can be set independently.

The pattern they wanted to prevent is const list: ArrayList = .{ .len = 16 } that segfault on next usage.

1 Like

does not encourage functional programming

you can define fn (type) → type, and you can also ++ comptime slices easily, so while it’s true that most of your Zig code is going to be imperative in runtime, there are quite a bit of FP opportunities in comptime. just a minor addition to your answer :slight_smile:

2 Likes

If you move the constructor out of the Proc namespace and into the ProcIdentity namespace then you enable this calling form (which I believe is considered especially idiomatic):

const procIdentity: ProcIdentity = .fromProcReference(procReference);

I took the liberty of renaming it from identity to fromProcReference as it seems more appropriate for its new home.

This calling form is not available for constructors whose return type is different from their namespace.

The main disadvantage of moving the constructor to the target is that you lose chaining. You can’t do

const z = (
    a
    .toB()
    .toC()
    .toD()
    …
    .toZ()
);

I am personally a big fan of this pattern in the other languages I work in but I don’t really miss it in Zig because we have blocks.

const z = blk: {
    const b: B = .fromA(a);
    const c: C = .fromB(b);
    const d: D = .fromC(c);
    …
    const z: Z = .fromY(y);
    break: blk z;
};

It’s maybe a bit more verbose, but it has the same namespace-nonpollution property and is a lot more flexible! Just think: it’s suddenly easy to slap a debug statement anywhere inside the block, whereas anyone familiar with the chaining style has surely fought against it at debug time, either laboriously breaking it back into variables just for debugging, or doing weird indirect stuff like jamming ‘print internal state but return self’ into the chainable API.

3 Likes

The pattern they wanted to prevent is const list: ArrayList = .{ .len = 16 } that segfault on next usage.

Tbf this is the one situation where fine-grained field-access rights are nice. Not necessarily public/private (that’s too brutish), but similar to how a file system manages access rights (e.g. readable, writable, “inaccessible” in specific scopes (e.g. anywhere outside the implementation module, or in specific modules).

true, and even

const z = blk: {
    const b: B = .fromA(a);
    const c: C = .fromB(b) orelse return error.BlaBla;
    const d: D = .fromC(c) catch return null;
    …
    const z: Z = .fromY(y);
    break: blk z;
};
1 Like

.init() is a syntactic sugar for Foo.init() which Is kind of a syntactic sugar for foo_init()

but yeah, I used to feel like everything should have fn init() Self but it really just gets tedious and prevents keeping trivial structs one-liners.

eg.

const Pair = struct .{ key: []const u8, value: []const u8 };

becomes at least 7 lines:

const Pair = struct .{
    key: []const u8,
    value: []const u8,
    fn init(key: key: []const u8, value: []const u8) Pair {
        return .{ .key = key, .value = value };
    }
};

which also means you can fit 7x less of these on a screen.

Nowadays I tend to postpone writing fn init() as long as I can, basically until I can prove to myself that it improves the production code.

In tests where I may need to initialize them a lot, i will just create a local fn makeFoo() instead of polluting the code.

eg.

test {
    const TT = struct {  // often already justified to exist for other reasons
        fn makeToken( ... ) Token ...
    };

    const t = TT.makeToken;  // sometimes even going this far helps
    try std.testing.expectEqualSlice(Token, &.{
        t(.word, 0),
        t(.whitespace, 3),
        t(.word, 4),
    }, try parseTokens("bla bla"));
}
3 Likes

Though I try my best to use data structures that are narrow enough where all possible values are valid, sometimes this is not possible. In these cases, I use constructors to communicate that there is only a few fixed ways to validly construct the struct. In my codebase, I follow the convention that presence of a method starting with init means the object needs special consideration on construction to avoid invalid values.

8 Likes

You’re right that for the case where no field defaults are overridden, then a decl should probably be provided rather than requiring use of .{}, and I should have said that.

But I think default fields should be used when they make sense, and don’t violate that rule that any combination is valid. Otherwise there is no good way to provide option structs, for example. If some new way appears in the language, then of course that may be better, but for now we do have a way to do it that makes sense.

1 Like

I thought a bit about your comment and realized that you can get more or less the same behavior as option structs even if you constrain yourself to never using default field values. Instead, you can just let your init take an anytype that is supposed to be a struct (likely passed as an anonymous struct literal) whose fields are a subset of the fields of the target struct. init would then reflect on the fields that it actually has, and provide its own values for any fields it doesn’t. The form at the callsite is the same -

x1: X = .init(.{ .a = 1 });
x2: X = .init(.{ .a = 2, .b = b });

but .init is generic and there is no concrete ‘Options’ type, which is of course a downside for readability and discoverability.

Personally, after trying to invent some sort of over-engineered repeatable metaprogramming construct for this sort of initialization, I just landed on this:

const bla = blk: {
   var bla: Bla = .init;
   bla.y = 23;
   break :blk bla;
};

It is a bit of a mouthful, but actually pretty readable and I can still have my const where it matters. Coming from C, I sometimes tend to overlook the power of block expressions.

2 Likes

IMHO, it’s one of the unfortunate limitations in Zig, I’d love to have some syntax sugar for spreads. This is just noise.

Another arbitrary limitation in Zig is that you cannot really do classic mutable builder pattern because you can’t use *T with const. I can see why is that but IIRC Rust allows that, or at least I remember that I could do mutable builders and save the result in const (let).

Yes, it does get annoying, I also do get annoyed when I have to type a lot, but Only one obvious way to do things is the zen. And I do actually appreciate that Zig’s language scope is this narrow – after having written a few (not even too big) programs myself, I basically never have to look up extra langauge constructs to understand other people’s code. It’s a peaceful life.


Zig just wants to be very explicit about where memory—and especially mutated memory—lives. Personally, if I saw the need for a builder pattern, I would probably go with something like this:

const bla = blk: {
    var b: Bla.Builder = .init;
    break :blk b.something(16).complexAction(a).build();
};

Again, a bit more of a mouthful than one might want, because of the block expression, but it does do the trick. But then, I haven’t been writing programs where this pattern would be very useful lately.

3 Likes