Friction in programming language design

I’ve been fascinated by this topic for a little while now, and there doesn’t seem to be much discussion around it aside from occasional mentions. I also want to discuss how other languages in trying to remove friction end up encouraging worse practices.

What I mean by friction is essentially making something more difficult or less convenient to do in order to steer users away from it without resorting to restricting that thing entirely. Andrew explains a little bit here. I really love how intentional friction is used in Zig to guide users towards better solutions to problems. Two of the bigger examples of intentional friction in Zig are vtable based interfaces and generic APIs. Another example would be anonymous functions, but I’m too lazy to write about that one right now.

Vtable interfaces

Zig doesn’t have a language-level feature for vtable interfaces, so you’re required to implement them manually. This isn’t difficult but it’s certainly less convenient compared to a language with interfaces built in. This lack of convenience is actually a good thing! Vtable based interfaces have some very serious performance implications: indirect function calls are more expensive and can’t always be inlined by the compiler, they require one more level of indirection to access, they’re horrific for data locality, and they complicate memory management. Interfaces tend to be overused by programmers used to higher level languages where runtime polymorphism is heavily encouraged, and the downsides of this approach are less impactful in a garbage collected language where everything is a reference anyway.

Generic APIs

Another instance of friction in Zig is writing generic APIs. Generics cause slower compile times and makes code harder to reason about in any language, so we should prefer to limit the amount of generic code in use as much as possible. In Zig, duck typing and anytype make generic APIs feel really “loose”, and as a result it doesn’t “feel nice” to write very generic APIs in Zig. I couldn’t come up with a better way to explain that, but I think if you’ve used Zig before you’ll know what I mean. This friction discourages proliferation of overly generic code.


And now for the opposite effect…

Lubing up

Yeah… it’s a gross term*, but it conveys the concept well. That is, causing harm by reducing friction in the wrong places. What I previously talked about was “making the bad things hard”, and this is essentially the opposite: “making the bad things easy.” This is a trap that seems easy to fall into if you aren’t carefully considering the design and incentives of your language features. Zig does an excellent job of not falling into this trap, other languages not so much. I have two examples.

RAII

Oh boy! HN boomers, avert your eyes. Allocating heap memory is expensive. Good code batches memory allocations. In good code, there are very few points in the program where memory is actually freed. In good code, RAII is almost completely superfluous and useless. RAII is for bad code, where every single object gets its own malloc/free and pointers “own” their memory. This is a horribly outdated, slow, and Java-esque way of managing memory that introduces massive amounts of complexity and performance issues. RAII is a bandaid “solution” to manage manufactured complexity, when the real problem is much more fundamental. When you bring this up the reply is always, “Don’t like it? Don’t use it.” But the problem comes back to incentives. RAII makes it easier to write terrible code, so its existence actively incentivizes people to write worse code. When the bad things are easy to do, people are going to do them more!

Map vs forEach

I have seen JS code in the wild that used array.map for in place operations, and I have a feeling it was entirely because using forEach or writing a for loop would take more characters to type and look slightly uglier. I wonder how prevalent this is, and how many CPU cycles have been wasted in aggregate because there was the tiniest bit more friction involved in using forEach instead of map. The fact that it’s non-zero makes me sad. Preferable solutions like forEach should be easier to reach for than worse solutions like map, so I imagine a little bit of intentional friction could be introduced here to discourage code like this. Of course, JS is full of terrible design decisions like having class based runtime polymorphism through inheritance but not having f*cking enums or a good switch statement, so changing that one thing would not stop JS programmers from torturing CPUs with bad code.

End

Do you have any more examples of intentional friction in Zig, other languages, or software in general? I suppose a large downside of having this kind of friction is that there’s users who encounter it and instead of going “maybe I could solve this problem in a better way”, they instead go make an issue on the issue tracker demanding you to lube up.

I’ve been thinking about maybe turning this into a blog post which would elaborate more, so that’s why I want to see what other people here have to say about this.



* Literally making it easier to get f*cked, hence “lubing up”

10 Likes

Agreed, for your blog post I’d add that lack of interfaces also mean fewer useless abstractions and no trait-hunting to figure out what the heck is going on.

Another useful kind of friction, which came up on a stream today, is explicit allocators. This invites people to think about whether or not allocation is needed at all. Thanks to this we now have plenty of non-allocating libraries.

3 Likes

I agree, Zig is the language where friction is used most effectively. When coding in Zig, I often write very simple code because the language prevents complexity from escalating. In contrast, with C++, it’s all too easy to produce functional code that’s messy and inefficient.

In C++, if precision isn’t a priority, if you don’t care about perfect move/copy semantics, you can write sloppy code, rely on std::exception for error handling, and simply let RAII and excess allocations do the work. That convenience makes it too easy to just cut corners everywhere to get something working, thinking we will “refactor it later”.

With Zig, you can mimic that approach, but I find that it doesn’t work well. Overusing constructs like try often just doesn’t let you scale your “prototype”, and improper use of allocators or mismanaged lifetimes quickly causes errors. Similarly, writing overly generic code forces you to switch on tag types and handle many details manually.

A good example is a small non-blocking HTTP/1.1 web server I built in C++98. Despite its horrendous structure heavy on allocations and deep copies it worked and used a crazy 3MB of memory.

When I ported it to Zig, I initially tried to implement it 1 to 1, but because Zig doesn’t really scale well with this kind of complexity that comes from bad code, it just didn’t work, I had to take a step back and rethink how I did it.

In the end the Zig version not only worked flawlessly without heap allocations but also resulted in cleaner, safer code. This isn’t because Zig is inherently better than C++, but because the “easy way” in C++ often leads to suboptimal code, while Zig forces you to address error handling and memory management directly.

At least that’s my experience, in Zig is really “harder” to write bad code, if that make sense.

6 Likes

I know this is probably just me, but I feel the lack of interfaces helps get things done instead of over-abstracting everything, especially when coming from a programming culture that emphasizes the use of design patterns as much as possible.

As for generics comptime gets the job done elegantly as long as you are willing to be verbose in the way of specifying the types manually instead of having them inferred by the compiler.

Funnily enough I’m actually getting quite used to the lack of closures. The callback-context idiom works well enough in all cases I’ve encountered. I’m a little unsure of the

const func = struct { fn f() void {} }.f;

construct tho, it looks fine to me but I wonder if it’s readable to someone new to Zig.

1 Like

About your ‘lubing up’, I feel it’s not a balanced view.

WRT RAII: it’s not HAII, it’s RAII, and IMO that’s an important distinction. I’ve used RAII to orchestrate scoped/stacked and ordered release of IPC resources reliably that otherwise would’ve been annoying at least, and most likely more error-prone. I miss RAII. We have friction in the language to make sure you release a resource because we cannot tie deallocation and allocation together in one mechanism. Also allocating heap memory doesn’t have to be expensive, and specifically when you support allocators (like zig, or the C++ STL) you can quickly configure yourself into a fixed buffer allocation scheme. If your whole criticism of RAII revolves around the allocation scheme, that argument crumbles quickly.

Same with map vs. forEach or recursion vs. iteration or … if your compiler sucks, you’ve lost already anyways. It doesn’t make sense to talk about friction in that case. If it doesn’t suck, these semantically equivalent idioms shall result in the same code and accomodate your preferred way of thinking about the problem at hand. I don’t think the specific for-loop-vs-Array.map example has to do with friction, but with failure.

7 Likes

I don’t believe the criticism is about RAII itself. Rather, RAII like any tool can be used well or not, much like using a desk lamp to drive a nail through mashed potatoes. RAII isn’t flawed, its effectiveness depends on intentional use. When the standard library is designed around accepting an explicit memory allocator, and even supplies multiple allocator, it encourages you to think beyond objects. Suddenly, you’re considering memory layout, cache behavior, grouping, and lifetime management.

This mindset is achievable in C++'s STL, but because it introduces more friction, it might not be the immediate reflex. Similarly, error handling in C++ has improved, yet exceptions remain problematic and obscured, often leading to less practical error management. The critique isn’t so much about the features or language differences, but about how constraints can steer developers toward better solutions. While it’s possible to craft C++ code that matches or exceeds Zig in quality, my experience suggests it requires greater amount of care, time, and expertise it might be rewarding but It’s not always practical.

1 Like

I like Zig’s manual and explicit style better than RAII, probably because I don’t like constructors and destructors and I really like Zig’s:

  • Resource allocation may fail; resource deallocation must succeed.

Having destructors that themselves also can fail is more complicated than what I want to think about. And in the few cases where you have to deal with an API where release can fail, you can decide on some way how to wrap/manage that.

I also prefer to decide on my own when and where something is considered initialized, some things require multiple steps before they are actually initialized.

With Zig it is easier to exit without calling deinit when you have programs where you know that you don’t care and just want the program to exit quickly.

I dislike programs that take a long time to quit, or even worse are programs that start allocating/growing memory while they are supposed to shutdown, because they do annoying/unnecessary things. (For the latter I suspect that a common culprit may be runtimes that use garbage collection (whether it is in the application language or the scripting language used by the application) and try to call finalizers which themselves can cause new garbage (and new gc pauses) and make things alive again).

While RAII doesn’t force you to write a program that can’t early/quickly exit, I don’t think it makes it easier to create such a program and I don’t really see how it would help with that.

2 Likes

I have to say that just using try and returning ! everywhere is very easy in Zig. Maybe too easy.

2 Likes

I definitely dislike it more when someone uses anyerror because they are too lazy to define an explicit error set, particularly bad within libraries, I think that is worse than using a lot of try and implicit error sets.

I think with try it depends on the code, what the resulting error sets are and whether you still handle errors close to where they originate.

2 Likes

I used to think it was bad, but after using Zig for quite a while now, I’ve grown to understand that it really doesn’t matter, if you have errors, and the only thing you do throughout your program is try. There quickly comes a point where your program just doesn’t run long enough because it keeps crashing ahah. For example I’m currently working on implementing a multiplayer server side pong game. I’m implementing the networking myself, I swear that using try just doesn’t scale at all. if I hadn’t taken the time to handle expected errors, vs fatal ones, my thing wouldn’t be running for a lot of time ahah, at least not long enough for me to be able to work with it.

3 Likes

I also don’t like RAII, I think it puts you in the wrong mindset, because in languages with RAII, you just don’t need to really think that hard about memory management. I think Rust lifetime annotation and ownership are better than RAII, but it’s really not my cup of tea.

I like to manage memory, think about it, and it’s so nice when you can orchestrate memory. Even in C, when I used to code mostly in C (which I still do sometimes) I had my own “std” heavily inspired by Zig, with everything that allocates memory, taking an explicit allocator. To this day I still don’t understand why the C standard library doesn’t ship with a header file with some custom allocators.

At least a standard arena would greatly improve things.

1 Like

I am getting a bit weary of zig grammar driving undocumented patterns. To me, the intentional known unknown is a huge source of friction that I could do without!

:slight_smile: I realize it’s a roadmap item, and like all things, the team needs time for this stuff to cook.
I find I struggle with language patterns more than I do with the thing I am trying to pattern.

For example, an external C function w/*void that requires a NULL pointer is difficult.

  • I can’t just pass in null because null is poopy.
  • And what is a *void anyway*?
  • ?[*:0]u8 etc manglement is thomas-hasn’t-seen-such-bs.jpg territory :slight_smile:

And heaven forbid a function dare pass in a const, repent sinner for ye have sinned!

Only kidding…sort of.

1 Like

I can’t just pass in null because null is poopy.

What you mean?

And what is a *void anyway*?

It’s c version of a top type for pointers.
In zig it translates to *anyopaque.

1 Like

For example, an external C function w/*void that requires a NULL pointer is difficult.

I’ve been using C library functions that require void pointers without any issue.
@cInclude automatically translates void* to ?*anyopaque, which does allow passing null.

4 Likes

This is merely the Zig’s attempt to capture the precise meaning of ambiguous C types such as char*. All those semantics that in Zig are denoted explicitly – nullabity, array size, presence or absence of a sentinel value – are also present in C; you just don’t see them in the pointer types.

3 Likes

Learning Zig it took me a while before I understood the differences between different Zig pointer types and for a while they felt overwhelming and complex, but over time I understood them better and then I started to appreciate that they are explicit and precise.

2 Likes

Ah. MS WIN appears to behave differently.

zig’s relationship with T is a bit brutal, a conscious choice, which just happens to be magnified in the MS WIN platform…or at least how I understand it.

Below is a concrete example. Some MS Win API context…a common MS Win API convention is, a function, a handle, a void* buffer, other goesinsa/goesoutsa, and often…a reserved param that must be the 0/NULL pointer:

  BOOL  WINAPI FooBar(
    _In_             HANDLE hFoo,
    _In_      const  VOID   *lpBuffer,
   _Reserved_        LPVOID lpReserved  //MUST BE NULL
);        

How do we invoke from zig?

Representing void *lpBuffer in zig can be quite convoluted. Do I choose to represent as a true void in zig? Maybe, however now I’d have void casting, and if I have const sources of data…why am I doing this to myself.

So I read FooBar documentation, I see that the buffer is intended to be a specific data type – an input string. Suppose I know I will only ever have a constant null-terminated string as a read only input. Though I have to use my brain (booooo!), the result is potential clarity…a zig strong suit.

In zig terms:

pub extern "kernel32" fn FooBar(
  in_hFoo:      std.os.windows.HANDLE,
  in_lpBuffer:  [*]const u8   //*void if you are a masochist 
  lpReserved:   *void // must be NULL,
) callconv(std.os.windows.WINAPI) std.os.windows.BOOL;

Now, to invoke, what is reasonable? Should we use?

const rv = win32.FooBar(h,str,null);

As Office’s Dwight Schrute would say, FALSE!

 src\main.zig:x:y: error:expected type '*void', found '@TypeOf(null)`
        const rv = win32.FooBar(h,str,null);
                                      ^~~~

BOOM. No compilation.

hence…null is poopy.

Perhaps it is how i have represented *void in zig terms. Perhaps it is me - I could be missing a secret handshake / password in the build. Or some other concept I am missing.

But, for whatever reason, null does not compile. undefined … does.

const rv = win32.FooBar(h,str,undefined);

So this leads me to ask…what is void*…anyway?

Zig’s attempt to capture the precise meaning

While I get that, it is still malarkey and a PIA. Shouldn’t there be a more graceful solution?

Like I said, ?*anyopaque is what Zig uses internally when it encounters a void*. So that’s what you should use here.

3 Likes

@const-void If you are trying to use the win32 api have you tried using this GitHub - marlersoft/zigwin32: Zig bindings for Win32 generated by https://github.com/marlersoft/zigwin32gen?

1 Like