Ergonomics of ArrayListUnmanaged

Just test you code. DebugAllocator will detect incorrect free.

fn badFn(gpa: std.mem.Allocator, arena: std.mem.Allocator) !void {
    var list: std.ArrayList(u8) = try .initCapacity(gpa, 10);
    // use incorrect allocator
    defer list.deinit(arena);
}

test "DebugAllocator detects misuse of two allocators" {
    var a1: std.heap.DebugAllocator(.{}) = .init;
    var a2: std.heap.DebugAllocator(.{}) = .init;
    try badFn(a1.allocator(), a2.allocator()); // panic
    try std.testing.expect(a1.deinit() == .ok);
    try std.testing.expect(a2.deinit() == .ok);
}

Honestly, for all the real-world stuff I can think of THIS is the case. I’m going to build a PlaylistManager, and it’s going to have “other related things”… BUT… I still may be very likely to NOT hold onto the allocator that gets used for all the allocations and for the dealloc, because I might decide that it’s appropriate to convey, clearly, to the user of my PlaylistManager, which functions incur allocation. I might want to do the same thing with Io.

Now, honestly, for such a high-level idea as a PlaylistManager, I could be persuaded that all users of such a library would like to have nothing to do with allocation, and would be happy to be completely ignorant of allocations. Fine. I’d manage it from start to finish, within that struct, one field away from the container class I choose for the list. But I’d still want to use the “unmanaged” (default) container class, and send that allocator of mine every single call that requires allocation within.

None of that is an argument against providing a basic AllocContainer or Managed type which is just a struct with the unmanaged container and the allocator to use with it. Simple. I would just find it rather rare for my own uses, and think of it as slightly less ziggish to hide/encapsulate that detail.

2 Likes

In functions where it is possible to know the upper bound of added elements, the unmanaged style encourages to first reserve the needed unused capacity and then optionally use errdefer comptime unreachable to declare that no errors will happen beyond that point, followed by a bunch of calls of the *AssumeCapacity variant. I think writing code in that style (where it is possible) is good because it reduces the possible failure points, groups allocations, is explicit and doesn’t require passing the allocator or using try everywhere.

So overall I would say that: if using reserve-first happens more often, when there is only unmanaged variants in the standard library, then I prefer not having the managed variants.


Regarding the Managed(Unmanaged) in the absence of being able to automatically directly add adapted member functions*, where you don’t need to pass the allocator, having such a generic tuple doesn’t seem worthwhile to me.

Because try playlist.unmanaged.append(playlist.allocator, value); doesn’t seem easier than try playlist.tracks.append(playlist.allocator, value); with the latter you can use multiple lists that share the same allocator, give the lists meaningful names and you don’t repeat unmanaged everywhere.

*Zig doesn’t really support generating these sort of wrapper functions in a generic way, it could work via zigft library which uses platform specific assembly (part of me wants that library to become zig features, for example argument spreading as language feature, but I guess the fear is that this would allow generating layers and layers of automatic wrappers which are difficult to decipher)

I think the managed style would only be worthwhile if it was specifically adapted towards making the usage code easier and more fitting to the domain and possibly also easier to replace the underlying data structures, which are populated by that code. So I think for a more managed style it would make sense to focus on the actually needed operations (for example different ways of Adding / Removing / Batching / Filtering / Collecting stuff), basically creating a simplified API for specific operations and then underneath it would handle the unmanaged collection and allocator.

But I also think that if such a more managed interface is useful, it is quite easy to create it on the fly and then manually write some of those convenience functions, at least then those are actually fitting to the domain (and could even contain some checks or assertions), instead of over-engineered generic code.

3 Likes

Yes - do you have a favorite article/example/etc. that exemplifies this pattern well, for the record here?

I might have missed something, or there might be a typo here, but I think most are envisioning a difference that looks more like try playlistA.append(alloc, value) vs try playlistB.append(value), where playlistA is an unmanaged object and playlistB is a managed object; that is, playlistB is a PlaylistManaged { .alloc … } in which the alloc may be created and completely managed under its hood, or perhaps provided at init time, but then conveniently(?) forgotton/ignored by the user of the object thereafter. SOME who would create such a struct called Playlist are not only attracted to making the managed variant (perhaps ONLY a managed variant), but would also like for there to be a simple management mechanism in the STD that they could call upon, within their Playlist implementation, such that they don’t have to pass the alloc to every append() and insert() and what not, when they make calls to the underlying container class. I think I’m interpreting the gist correctly here….

1 Like

I find it misleading to describe that with:

From that piece of code, I literally expected the result type to just be:

struct {
    unmanaged: Unmanaged,
    allocator: Allocator,
};

Instead of:

struct {
    unmanaged: Unmanaged,
    allocator: Allocator,

    // with wrapper methods that are the equivalent
    // of .unmanaged.xyz without needing to pass the allocator
};

Another reason why I find it misleading to use such a generic function to describe it, is that there is no obvious way for this generic function to produce the latter within Zig.

Zig doesn’t allow you to generate arbitrary functions using comptime meta-programming, so we don’t have a way to magically generate the needed wrapper functions from some arbitrary input type that implements some unknown unmanaged-container variant.

That was one of the points I was trying to make.

To solve this, one of these needs to happen:

  • Zig could get more comptime-meta-programming features, so that we can write meta-programming code that automatically generates the managed variant (seems unlikely)
  • all the functions for every managed variant need to be created manually, like they have been std/array_list.zig (this solution is deprecated)
  • copy the deprecated solution and maintain it
    (likely some will do this, like it happend with BoundedArray)
  • use zigft to write a generic function that is able to automatically create a managed one
    (I think this would be possible, but would have to check by trying it)
    (But this makes your code really dependent on zigft)
  • use different (ugly) syntax like:
    var m: Managed(Unmanaged) = .init(allocator);
    try m.call(.append, .{my_item});
    
  • give up and use unmanaged instead

Personally I think “give up and use unmanaged instead”, requires the least amount of effort and headache and it even can have benefits, once you go beyond the most basic use-cases.

Wanting managed containers seems a bit like a xy problem to me, it seems more to me like people actually want something more along the lines of Odin’s implicit-context-system, because if Zig had something similar all the unmanaged variants could be used in a managed-looking-style, but with Odin it seems to work via named-arguments with default-values that use the implicit context and it seems Zigs desire to be explicit, excludes this sort of implicit context.

Ah, sorry. I was quoting @vulpesx‘s bit of code, there (pub fn Managed…) and didn’t mean to overly connect that. HOW you might use such a Managed struct with only those two fields is certainly debatable, but I guess, though odd to me, it could be that the “managed” Playlist implementation might have a field of type Managed(ArrayList), and, within the implementation of Playlist, the developer might use that (via myArrayList.unmanaged.append(myArrayList.allocator, node)or something of the sort)… but the user of the Playlist itself, by some other party, is another implication of what’s being discussed – there, playlistA.append(alloc, value) would of course only be possible if the implementer of the PlaylistA struct actually REFUSED to manage the allocator within. playlistB.append(value) could be implemented, within, by having an allocator field in the PlaylistB struct OR, perhaps, by using this Managed struct offered by @vulpesx, which holds the allocator itself - in both of these cases, PlaylistB would be taking “management responsibilities”. In neither case would an ArrayListManaged really be “necessary”, even if some found it a little convenient. The question is: does the perceived convenience outweigh this particular ethos in the language, about “hiddenness”, at least at the STD level and below.

Oops… didn’t finish reading before replying…

Ah, yes, you’re clearly a step beyond me; I wasn’t thinking of an elegant solution! :slight_smile:

That’s all that occurred to me! And, indeed… seems ugly, even though you offer a slimmer variant than I, trying to illustrate the awkwardness.

Managed(Unmanaged) was a silly idea I had in the moment, It was not meant to be a serious suggestion, more of a highlight of how trivial it is.

1 Like