Layered Programming (2013)
akkartik.name
akkartik.name
It seems too powerful, though. I could see it quickly becoming intractable to find where the code lives that is causing a certain outcome.
The root problem it's trying to solve is the fact that things often don't fit neatly in a single bucket, but our traditional idea of modules as mutually-exclusive and hierarchical categories forces you to choose just one place to "put" something. An idea I've had is to get rid of hierarchical modules altogether. Really they're a relic of directories and files. Instead, one could "tag" a function/class/constant as belonging to multiple modules, and one's editor could aggregate all members of a cross-cutting module as needed. Organization within actual files on disk becomes an implementation detail.
(Author here.) The tangler I describe inserts #line directives into the output. You can open up the generated C++ and see where a line comes from. Conventional C++ tools understand line directives as well, so the debugger experience is sane.
I'm sure there are gaps in this tooling, but they're not intractable. Tooling around conventional modules has matured over decades. My approach hopefully shows some benefits of a road not taken.
Yes, this is interesting. Are you thinking of something like Ruby's modules and `include` directives? (https://ruby-doc.com/docs/ProgrammingRuby/html/tut_modules.h...) Is there a reason the editor has to do this rather than the language?
Unfortunately I'm not very familiar with Ruby, so I'm not sure
> Is there a reason the editor has to do this rather than the language?
The language would definitely have to support it (though maybe you could accomplish something similar with annotations and/or special comments in an existing language), but without special editor support you wouldn't gain much from it. Your constructs would still be scattered across files in ways that make compromises. Whereas editor support could pretend the files don't exist at all.
My original inspiration was that English language is spoken sequentially, but ends up in a graph database (i.e. the brain's neural network). So you'd write code like a thesis, and the hooks you specify would allow the splicer to form the code graph.
In theory it seemed like it would be useful, so I built Pacman with it and, well, it didn't really work in practice! Things ended up being more abstract in the end, and I'd be mentally juggling where hooks were created and what the final code would look like. Even showing the code output to the right wasn't that helpful, as the way the code was spliced together put things in not the most readable order.
Additionally how to split up the code wasn't the most obvious, i.e. when should a hook be added? It often felt I'd only know after writing the code in full. At which point it just felt like extra work. I'd often find myself to be inserting code all over the place to get something working, and so the refactoring needed at the end became overwhelming. Ultimately these reasons lead me to abandon the idea.
- Don't use layers in too fine-grained a manner. Often if I need to change two lines in a function but they're separated by 6 lines I err on the side of replacing the entire paragraph or even the entire function. Each layer should be readable in a self-contained manner as much as possible.
- I never invalidate tests in an earlier layer. You never want to be in a situation where a layer lies to you about what desired behavior is.
Agreed! This is why I built this approach in a ~1000 lines of code. The ability for tools to create new problems was very much on my mind.
My current project using this approach is a bootstrapped computing stack from scratch: https://github.com/akkartik/mu#readme. It's at ~40k LoC (of machine code) and self-hosted, and it can create bootable images (when packaged with an OS kernel). I expect to get to approximately C's level of expressivity in another ~20k LoC or so. So I care very much about what I call the "thrust to weight ratio" of a codebase. The less code you have, the less spaghetti you can create.
My implementation of layers will keel over and fail terribly for large projects with many team members. I consider that constraint to be a good thing.
The analogy to `git log` is just that, an analogy. Here's a concrete thing that you get with layers that you don't get with `git log` or conventional abstractions and refactoring: alternatives. Often we see comments in codebases that some complex, hairy function is really a much simpler function with certain optimizations applied. There's been lots of work done to try to separate out functional spec from optimization so that the two can be managed separately. But this is all open research. Layers let you get the value of this separation today, with very little code (just a lot of reorientation in mindset). If you adopt layers you can use the simple version in an earlier layer, and then replace it in a later layer. Your CI system will exercise both, and flag if either stops passing tests. What used to be a code comment now stays up to date.
I responded to tooling questions at https://news.ycombinator.com/item?id=21766557#21767393
When debugging things, however, you often want to see the big picture of how the layers come together. In those situations I look at the generated C++. It's designed to be clean and easy to read, and it has #line directives so that you can tell what layer each line came from.
Guess I'd have to use it for a bit to get a feel for how it is in practice. My initial reaction is that it just moves the complexity around a bit, without really helping much.
I should have been more hygienic in this old prototype. But honestly this idea of a waypoint being a hint is something I just thought up. Mostly my mindset is that layers should be oblivious to later layers. Ditto for readers; the goal is for you to not have to worry about later layers when you're reading a simple version.
> My initial reaction is that it just moves the complexity around a bit, without really helping much.
From OP: "We aren't just reordering bits here. There's a new constraint that has no counterpart in current programming practice — remove a feature, and everything before it should build and pass its tests."
If this doesn't seem valuable, I probably can't persuade you. In general I've grown a lot less concerned about how code looks and a lot more concerned about having lots of different runnable versions of a single program that a reader can play around with to build up an instinct for it.
Each unit test in a program is conceptually a distinct runnable version. Layers push further in that direction.
But without the documentation around it, I'm not sure I feel the same way about it. It feels harder to reason about what where why.
Also, I'm assuming bugs should be fixed without using this layering. After all, without that layer you got broken code. But often I find there's not a clear-cut distinction between feature and bug fix. What then?
Anyway, interesting idea, would have to try it to get a feel like I said.
[1]: For example http://www.pbr-book.org/3ed-2018/Sampling_and_Reconstruction...
> But without the documentation around it, I'm not sure I feel the same way about it. It feels harder to reason about what where why.
I don't understand this. Are you saying my approach doesn't permit documentation? I don't see why. Check out the start of the first layer: http://akkartik.github.io/mu/html/000organization.cc.html
Do you mean that the documentation doesn't look as nice as Literate systems? I think that's a good thing: http://akkartik.name/post/literate-programming. Giving important concepts in your program a good central place is the essential need of documentation. Layers give features a single consolidated place (a https://en.wikipedia.org/wiki/Focal_point_(game_theory) ) where you can include documentation and be confident that someone interested in the feature will find it. Next to this attribute, I think typography is almost meaningless. I think it's far more important that documentation be accessible from within my programming environment, than that it be typeset well.
> I'm assuming bugs should be fixed without using this layering.
Yup! From the final paragraph of OP:
"Codebases have three kinds of changes: bugfixes, new features and reorganizations. In my new organization bugfixes modify a single layer.."
I have to admit I don‘t have a whole lot of experience with large codebases, but this seems a bit like premature optimisation. (After all, this is just pushing the work of really understanding the code base away from the initial reading phase and on to the actual debugging/extending - which is the phase most work is done in.)
Also, how do you deal with unexpected interactions between layers, especially when you don‘t see all the layers as you implement a new one?
No, I've seldom tended to look at the generated C++. It's an escape hatch of last resort. Mostly I live in layers.
> how do you deal with unexpected interactions between layers, especially when you don‘t see all the layers as you implement a new one?
If you're constantly having to think about interactions between layers, you probably are trying to over-use layers.
Did you ever find yourself creating git commits that tend to change one line in some place and add a function someplace else? That's a sort of classic use case for layers.
But I'm not sure I can convey this any more clearly through prose. Perhaps what would work is for you to try to read a codebase built in this way, to get a feel for what I use layers for. Candidate codebases:
https://github.com/akkartik/mu#readme
https://github.com/akkartik/mu1#readme (an earlier prototype that uses very different mechanisms, and entirely different code)
If that level of maintenance isn't being performed (which is almost a given, assuming we are looking at a problem with such a patchwork problem), then this new tool won't be fully followed and the end result will be a patchwork code base that reaches nightmarish levels where some features are added in this layered approach, but other features are added directly to the code, and even other features are added directly to prior layers.
If the tooling force people to use this in the recommended way then it may have interesting payoffs that are worthwhile. But lacking such an enforced requirement, I don't see applying being beneficial except in the code bases that least benefit from it.
These days you see a weaker form of this in the form of annotations in e.g. Python or Java or macros in Rust. Spring actually uses an AOP library that has its roots in AspectJ.
I actually prefer a more recent trend in e.g. Kotlin to use internal DSLs instead of injected magic. The advantage is that it is simpler to debug and part of normal type checks, autocomplete, etc. since technically all you are doing is working with the language instead of augmenting it via generated code.
Instead you have to properly clean tech debt from time to time.
I'm not alone in this scepticism. Check out Alan Kay's STEPS project from a few years ago. The goal was to create a complete computing stack in 20k LoC.
My current project has a similar goal: https://github.com/akkartik/mu#readme
You mentioned tech debt. I think I have a more aggressive definition of tech debt than most: any dependency you rely on that you don't understand the internals of is tech debt. It's fine to take on in the short term, but you should be planning to pay it off in the long term. The approach modern software takes, of continually adding dependencies (each of which is itself growing monotonically complex) faster than anyone can internalize them, this approach feels utterly insane. Under this definition, my tiny tool is the least of someone's problems if say they depend on 60 Ruby Gems.
Look at my definition of x86 instructions: http://akkartik.github.io/mu/html/013direct_addressing.cc.ht... If this is tech debt, I'll stay in debtors' prison, because it's safer inside than outside.
IMO, if you understand HTTP verbs, headers, response codes, body and the basics of REST. The beauty is that you don't need to understand the internals of every layer on the stack.
In fact you can be unconcerned with whether libcurl does HTTP 1.1 or HTTP/2 on your behalf.
How about we measure mean time between security incidents. Spend time understanding your dependencies in order of their security records. And you just have to learn enough to go noping away from it. Once you delete a dependency you don't have to waste your time on it anymore. Does that seem useful? If it just gets you thinking about the full cost of ownership of your dependencies, I think chatting with me has paid for itself :)
(None of this applies if you aren't providing a service to others, of course. If you're just learning, use whatever you want!)
I think this (implicit in most cases) belief that OSes are inherently complex and therefore require mounds of code is a result of our current computing culture and the expectations set by the open source movement. OSS/FOSS is great, but has kind of frozen things at Unix. We have sacrificed simplicity and comprehensibility in the name of platform compatibility.
It's not just STEPS that shows another way, as compelling and underappreciated as that project is. Older systems like Oberon, Smalltalk, and even the Lisp Machines were examples of systems that had a different idea of what "simplicity" means. Unix principles, Posix architecture, x86 -- these things are not natural laws. They are artifacts with immense historical and cultural inertia.
What's worse is that today we have widely accepted and utilized standards (for communication and data formats) that we didn't have back in the days of greater computing platform diversity. If anything this should be leading us to create exotic systems once again, now that they can better inter-communicate and translate data. But we aren't -- and not for technical reasons.
We should make a rule that if the full computing system cannot be reasonably described in a book, then it isn't simple enough.
This is a problem you want to have, of course, because you want to ship because you want to have revenue because you want to have earnings -- or just because you want to ship open source and keep a user community happy even if you make no money from it.
Staying compatible with everything forever is equally bad, but we only realize that over relatively long timescales.
I think there's a middle-ground between these two extremes that hasn't been explored: when you make an incompatible change, fork. Patch only the worst bugfixes and security fixes on old forks (but continue these indefinitely). Point new users at the latest fork in each point in time. Support switching forks on your users' schedule.
This approach adds some overhead for authors and maintainers. But I think it's not as much as we fear. And it can be automated over time.
There's a selfish reason this never happens, but we don't talk about it in polite company: if you start forking yourself you make it more acceptable for others to fork you. I think that's a good thing. We should all stop playing shallow politics and encourage being forked by others.
Context-Oriented Programming: https://arxiv.org/pdf/1105.0069.pdf
Feature-Oriented Software Development: https://en.wikipedia.org/wiki/Feature-oriented_programming
The big difference between my approach and these is the weight-to-thrust ratio. These academic approaches tend to assume people don't want to change the pieces, just combine them. And they create huge tools to make that possible. Why can't people just change the pieces? I've never understood that. I don't need an algebra of program transformations intermediating between me and my code. I'd much rather hold the code directly in my mind. And that keeps the tooling needed really, really small. Just a hundred or so lines of C.
Tooling is a double-edged thing. Adding tooling often encourages increases in codebase scale. Keeping tooling rudimentary can be a good thing by keeping codebases small.
Could "splicing in" code it be implemented using aspect-oriented programming frameworks? https://en.wikipedia.org/wiki/Aspect-oriented_programming I've never used them though.
> There's a new constraint that has no counterpart in current programming practice — remove a feature, and everything before it should build and pass its tests: > build_and_test_until
Looks like added tooling is needed, but seems worthwhile.
I believe this is a lot more tractable with Unision's[0] content addressable hashing. It's kinda made for that although more with an eye towards updates to distributed systems.
It was a complete nightmare to reason about and work with, even for me, and especially for newcomers. A natural way of trying to understand a system is to look at its entry point. With a system like this, it looks like a skeleton:
let di := DependencyContainer.make()
let moduleSystem := ModuleSystem.with_container(di)
di.register_singleton[IModuleSystem](moduleSystem)
moduleSystem.find_and_register_modules()
Where do we go from here? The next logical step is into `find_and_register_modules` or into the documentation for the module system (just kidding: I was a "senior engineer," I didn't write docs).We are left with a lot of questions. How does anything happen? It's somewhere inside the modules, but which one? Does the order that modules are loaded matter? The answer is maybe. Clearly, there are places where order of operations matter. For example, the main navigation should be ordered. How do we accomplish that? (The answer for my system was to have the interface for navigation extensions specify there was a `readonly weight: Float64` attribute. But then to actually understand why things are in the order they're in, and to get things ordered correctly, you need to look up the values for other navigation items. It's secret coupling.)
More recently, I saw a team at my previous company build a system like this. There were dozens of interfaces and implementation classes and code for composing all of this, and the end result was you couldn't just go somewhere and see what was happening. What we really want to see is:
match maybe_user
Some(&user) => nav.add(UserProfileNavItem.for_user(user))
None => nav.add("Login", "/login")
nav.add("Browse", "/browse")
nav.add("Search", "/search")
nav.add("Help", "/help")
Do you have to make a change here whenever something gets added? Yes. You do. But the idea that your program needs infinite flexibility in all areas is simply wrong. And, in fact, everything ends up secretly coupled and inscrutable.For this reason, my advice has long been to limit extensibility to specific, narrow use cases (for example, filters in an image editing program). Do not build entire systems around extensibility. You are not building an abstract system, so don't waste your time with unnecessary and obscuring abstractions.
I mentioned a couple of rules of thumb for when I create new layers here: https://news.ycombinator.com/item?id=21766557#21767499
Here's the entry point for my current project which uses layers: http://akkartik.github.io/mu/html/000organization.cc.html
You can find a list of layers here: https://akkartik.github.io/mu1 (URL is slightly different; it's a previous prototype. But should suffice for this thread.)
It's like AOP with user-definable join points.