Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal: Add parent attribute for pointers to struct fields #9724

Closed
topolarity opened this issue Sep 10, 2021 · 1 comment
Closed

Proposal: Add parent attribute for pointers to struct fields #9724

topolarity opened this issue Sep 10, 2021 · 1 comment
Labels
proposal This issue suggests modifications. If it also has the "accepted" label then it is planned.
Milestone

Comments

@topolarity
Copy link
Contributor

Motivation

We'd like to prevent some of the existing foot-guns with Zig's current preferred pattern for run-time polymorphism via @fieldParentPtr (#591)

In particular, the foot-guns associated with this pattern seem to be of two kinds:

  1. Sub-object mistake: This form of polymorphism requires the user to pass a pointer to a vtable embedded in an object, but the vtable was accidentally copied out before its reference was taken
  2. Lifetime-related: A pointer to a vtable embedded in an object is left dangling after the object is de-allocated

Since lifetime-related problems are a well-understood (although un-resolved) footgun in Zig and have impacts well beyond this particular usage pattern, these are out of scope for this proposal. Instead, the goal will to be to tackle the unique foot-guns that occur with using a pointer to an embedded vtable: the sub-object mistake.

The central idea is to add additional pointer attribute(s) to make pointers to embedded objects (&x.f) distinct from pointers to isolated objects (&f).

Doesn't pinned already solve this?

The pinned proposal claims to solve both of these problems for this usage pattern, but it does so by over-restricting the behavior on these objects. For self-referencing structs, the move restrictions associated with pinned are appropriate, but in this case, the struct is not self-referencing. The pinned solution prevents moving an object with an embedded vtable, a much stronger restriction than necessary for safely using an object with a runtime-polymorphic function.

Furthermore, pinned does not prevent the accidental usage of a vtable that was instantiated outside of an object to begin with (and therefore is still not embedded in any object).

Proposal

This proposal builds on the erased type parameters of #9723. Please take a look at that proposal to see how erased is intended to work in general.

We add an additional pointer attribute parent(T, field_name), or when unambiguous simply parent(T), which is automatically added to a pointer generated via sub-field access (&x.a).

We can now adapt fieldParentPtr to use our new attribute: @fieldParentPtr(type, []const u8, *T) *ParentType becomes @parentPtr(* parent(U, ...) T) *U.

Here's what an extremely basic example of an embedded vtable looks like now:

const VTable = struct {
    foo: fn(* parent(erased) VTable) void,
};
const CountingAllocator = struct {
    allocator: VTable = .{
        .foo = fn(vtable: * parent(@This()) VTable) void {
            const self = @fieldParentPtr(vtable);
            // ...
        }
    }
};

The immediate benefits are:

  1. It's now a compile-time error to accidentally pass a reference to an isolated VTable object.
  2. Since the compiler sees that we are casting a fully-specialized function (fn(* parent(@This()) VTable) void) to a runtime-erased function pointer (*fn(* parent(erased) VTable) void), it is in theory able to insert a debug-mode check to verify that the erased attribute matches the type of the function ultimately called. This verifies that the dispatch table embedded inside the struct is type-compatible with it.

Casting Behavior

As you'd expect, a pointer with this attribute eagerly "strips off". That is, * parent(...) T casts automatically to * T. Functions accepting these parameters naturally vary in the opposite way. That is, fn(* parent(U) T) void casts automatically to fn(* parent(erased) T) void (inserting a debug check), but does not cast automatically to fn(*T) void

Extension: Sub-ranges of a slice

Since we're tracking sub-pointers in general, it may be reasonable to also add a parent(U, offset) attribute, possibly with a different name like subrange, which would allow for statically encoding an "offset" pointer into a buffer. This allows the type-system to encode, e.g. a slice that is actually used to access a region with extra data slightly out-of-bounds (effectively a header or footer)

Appendix: Syntax

I'm not completely satisfied with the verbosity of the * parent(P, field_name) T syntax or the overloaded nature of the attribute. Improvements in this area are more than welcome, if anyone has ideas

@Vexu Vexu added the proposal This issue suggests modifications. If it also has the "accepted" label then it is planned. label Sep 10, 2021
@Vexu Vexu added this to the 0.9.0 milestone Sep 10, 2021
@topolarity
Copy link
Contributor Author

topolarity commented Oct 8, 2021

After talking with @SpexGuy for a minute, it occurred to me that this could be made even safer if the compiler included comptime fields in the memory layout of a type:

const VTable = struct {
    foo: fn(* parent(erased) VTable) void,
};
const CountingAllocator = struct {
    comptime allocator: VTable = .{
        .foo = fn(vtable: * parent(@This()) VTable) void {
            const self = @fieldParentPtr(vtable);
            // ...
        }
    }
};

It's now impossible to replace the vtable with the wrong one. Together with * parent(erased) Vtable, this is enough statically prevent the sub-object mistake.

As a strange(?) bonus, a compile-time polymorphic function can in theory use the interface in the same way, without run-time dispatch overhead. For example:

fn comptime_polymorphic(x: *parent(erased) VTable) void {
    x.foo(); // Run-time polymorphic
}
fn comptime_polymorphic(x: *parent(any T) VTable) void {
    x.foo(); // `foo` can be resolved at compile-time, if VTable is a comptime field in T
}
Layout impacts

The compiler currently doesn't include comptime fields in the struct layout, instead removing them as an optimization. Luckily, there's a simple rule that lets us know when we actually depend on having the comptime field in the layout:

The comptime field x: U can be removed from the T's layout if @parentPtr() is never used on a * parent(T, x) U

On the other hand, if we want to guarantee that comptime fields never need to be included in the struct memory layout, then we can give up this particular use case and prohibit using @parentPtr on a pointer to a comptime field.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
proposal This issue suggests modifications. If it also has the "accepted" label then it is planned.
Projects
None yet
Development

No branches or pull requests

3 participants