Accidental complexity in "friendly" main program, a mini case study

If you need quick hands-on experience with accidental complexity, just try reproducing a computational experiment paper, which I unfortunately did recently, and very likely you will find these extremely helpful features:

  • The main program finds the dataset by name and parses it, so you don’t need to worry about paths and file types.
  • The main program puts the results in a pre-configured well-known path, so their evaluation code can find it.
  • The main program finishes the experiment and calls evaluation code, so I don’t need to know where the evaluation code is.

Of course, all of these features were buggy. When I ran into bugs, I needed to look at all the implementation details the author imagined I could skip. In the end, we could have been better off writing documentation for the core functions, instead of writing a “friendly” main program.

But why do people keep writing “friendly” main programs? A great amount of accidental complexity is repeated in every program not because of programmer incompetence, but systemic reasons. The following important problems are not adequately solved by the UNIX operating system/programming environment.

Persistence

Persistence is already a widely agreed requirement of substrates.

If the main program does not include unnecessary I/O code, the convention is to use standard input and output. Standard output is not persisted and also dangerous (control characters). Standard output is by default unreliable: it scrolls too far, breaks the terminal, and is not saved.

But we also have shell redirection, and it has none of these adverse effects. Why are people not using it? I can only guess: maybe the shell is not “proactive” enough; users want the shell to save the output first, then let them consider other options. This way they have one fewer thing to worry about. (Feel free to share your theory.)

In conclusion, if there is only one output stream, a mandatory --output-file argument is just unnecessary complexity. We should simply modify the shell to persist what the user wants. Although uncommon in practice, this can still be done with more output streams.

Of course, my previously proposed nonlinear operating system should be a systematic solution to this problem.

File Types

If the operating system is a programming language, each variable is a “file” which already has a type. This is not true for the UNIX file, which is always a byte array. So, the main program needs to write file type abstraction code.

On one hand, this “programming language as OS” stops making sense if you have byte arrays everywhere like a regular filesystem. It really is necessary to have typed variables everywhere. If the abstraction layer is present, the serialized presentation is not important at all. Are we ready to demote serialized representation? Let the runtime take over all serialization?

On the other hand, it is conceptually sound to have binary formats, as long as the user knows the format. You can always build abstractions on top. Untyped filesystems are successful because they care no more about bytes. Additionally, the byte array file can be used with any abstraction implementation, while a variable of a certain type can only be backed by a single abstraction implementation. Are we ready to lose this flexibility?

My suggestion: Have a type-agnostic kernel, and allow multiple independent abstraction views of one underlying data.

Core Function Discovery

The main program can only be as helpful as its help message, which shows the core (business logic) functions I can call, and what I need to do to be routed to each core function. Without a main program, how will I discover the core functions?

Let’s consider static analysis. We can get a dependency graph of the functions. The unused functions (no other code calls them) are definitely entry points. But some used functions can also be entry points.

The ordering of the functions can also be exploited. The author has usually decided to order them in a certain meaningful way. Many IDEs reorder functions lexically in a context, which is really detrimental to function discovery.

In general, since this is quite subjective, I think no one but the author must indicate the core functions. For example, they can annotate core functions to be displayed with more importance. They can also write a literate example program that calls all core functions.

Note: I did not mention “exports” because I still want to be able to call the helpers. They just need to be very unimportant when I initially explore the code.

The last point, “Core Function Discovery”, has a lot to do with UI. UIs try to make it easy to discover things, by having things where the user expects.

If this is the case, we should really be writing READMEs instead of main programs.

This maybe not a highly relevant anecdote, but something I’ve been thinking about while tinkering this week.

I discovered that the Fish shell wraps cd with a Fish function to implement directory history. The wrapper simply persists a history stack using shell variables (see the source by runing funced cd or look here).

I use a tool that does a computationally intensive task. Power-users of the tool know that internally work is done in two stages, the first is computationally intensive and the second stage is both that and network intensive. After the first stage an estimate can be made of how much work the second stage is. Unfortunately most users use the one-shot porcelain binary and power-users come up with a manual or custom solution if they want more control.

What I can do is recreate the interface of the porcelain program as a Fish wrapper but using the techniques I found in cd I get extra features like interrupting and resuming work using state history at almost zero marginal cost. A huge benefit is that I can run funced to examine the wrapper whereas for the porcelain main program I need to navigate some C++ codebase.

It makes me suspect that there are many cases where the friendly main program is a premature composition that would be better as a shell script that users can study and modify. 9FRONT is doing this right (see 9fs) but Linux not so much (think mount).

4 Likes

A provocative question: why do we expect a porcelain in every software project?

Given users are already able to compose the programs, like in your case.

My answer is: the diversity of users. Power users want scriptable tools that they tie together themselves. Occasional and inexperienced users want a specialized UI with discoverable functionality.

The diversity of users means the diversity of experience levels, workflows, goals, and environments. So, the developer is expected to cover many such common situations. Of course.

Let me shift the focus to discovery.

Must there be a single porcelain entry point? (I’m looking at you, apps.) As discussed above, we probably want multiple entry points.

Consider:

  • FFMPEG, which has the entry points ffmpeg, ffplay, ffprobe.
  • ImageMagick 6, which has the entry points convert, identify, mogrify, and more. In version 7 we no longer have these separately.
  • Gradle, which has system-wide and in-project wrapper (./gradlew) distribution formats.
  • BusyBox, which provides a large number of utilities in one binary. I put this last, because each entry point leads to a very different core function, unlike most of the above.

These do not have to be multi-call like BusyBox. ffmpeg will just load the core library and do encoding, and ffplay will just load the core library and play something. ffmpeg and ffplay can be separate wrappers without sharing anything but the core dynamic library. They perfectly satisfy two demands, while removing subcommand routing.

That is, given users can discover the different commands. If multiple entry points are extracted to the system level, the system must responsibly offer discovery features. But the package manager does not tell users what commands are contained, what man pages are contained, or even show any documentation, when they install a package. The user is forced to discover by guessing the command name, and trying --help. This is the gap!

Also imagine if apps can have more than one icon on the home screen. Don’t we remove a ton of UI code that only routes the user to real features? In fact, this is supported by Android, but app stores don’t like it.

I’ve certainly seen these concerns in academic projects. Doubly so when done by students who move on.

Using stdin/stdout + README suggesting redirctions has a major weakness: Files don’t describe their provenance.
When people exchange some inputs + precomputed outputs, it’s hard to be sure what command(s) were [can be] run to [re]produce them. And user-chosen names (latest-2_good.preprocessed-copy) make this worse…

Hence the temptation to hardcode in/out file name conventions. If done well (e.g
out = in.suffix), it makes data discoverable — but comes at cost of composability.

  • main.sh script keeps thes clearer than main.py etc. Costs: (A) now you have a polyglot project, which demands more from the people involved. (B) It’s more OS-specific? WSL half-solved this, now bash is universal enough. (C) Writing porcelain wrappers is more (buggy) code than calling Python etc. directly.

  • Makefile can be even better! But fewer yet speak it well.

What the Unix substrate still lacks is a shell notebook/spreadsheet, where derived files come with provenance. You could call it “madesialzed execution”.
Not necessarily an OS problem. A good convention/format + good UI could solve this(?). Decades after make, we’re still iterating on what that looks like, within Unix…

  • Technically jupyter has bash notebooks. Never seen them used. Github/gitlab rendering READMEs & notebooks OOTB is great! I miss both in file managers…
  • For looped/distributed long-running commands, GNU parallel has a format for restating which is handy (though idiosyncratic).
  • GitHub - apenwarr/redo: Smaller, easier, more powerful, and more reliable than make. An implementation of djb's redo. · GitHub is better than Makefile for encoding “this is what already run + dependencies”. Nobody heard of it.
  • Nix & Bazel are programmer-grade hermetic reproducibility, where exact code used is tracked as well. but apparaently to heavy for casual users?
  • Dockerfiles did achieve wide semi-casual use :tada:
    • Earthly attempted a more Docker-ish bazel. rip.
  • There were a bunch if “git for data” projects but I lost track…