The Unreasonable Effectiveness of Makefiles

Aug 12, 2022

Few tools walk the right line between declarative and imperative like make.

Make originated with a visit from Steve Johnson (author of yacc, etc.), storming into my office, cursing the Fates that had caused him to waste a morning debugging a correct program (bug had been fixed, file hadn't been compiled, cc *.o was therefore unaffected). As I had spent a part of the previous evening coping with the same disaster on a project I was working on, the idea of a tool to solve it came up. It began with an elaborate idea of a dependency analyzer, boiled down to something much simpler, and turned into Make that weekend. Use of tools that were still wet was part of the culture. Makefiles were text files, not magically encoded binaries, because that was the Unix ethos: printable, debuggable, understandable stuff.
ā€“ The Art of Unix Programming

make build a directed-acyclic build graph (DAG) of dependencies, and uses file modification times to decide whether or not the outputs need to be rebuilt.

Why is make so successful?

  • Does not manage the state itself. No database of file modification times, checksums, or build output files that could cause bad states to happen. Instead, make just compares the file modification times of the outputs to the inputs.
  • Not fully declarative. While this is an undesirable property for excessively large systems, it is good enough for 80% of projects (Pareto principle for build systems). Declarative-ish syntax for the rules you care about and simple PHONY rules for tasks you don't.
  • Easy escape hatches. Makefile syntax isn't great for complicated configurations. Using make as the entry point for a variety of bash scripts usually isn't too bad.
  • For most configurations, not excessively templated. In some ways, the tough-to-learn syntax has prevented Makefiles from looking like Helm charts or Jinja templates.
  • Other Lindy reasons we might not know.

Of course, there's always room for improvements in a 46-year-old piece of software. Some of these features might be out of scope, but a short list of ideas.

  • Reconciling docker builds with makefile targets in $ make docker. Many language-specific tools already do a weak form of this ā€“ yarn or npm will runs tasks in the context of your node_modules directory. Docker provides a much more generalized and cacheable substrate to run tasks. It also works well with DAG structure and make's semi-declarative nature.
  • First-class support for configuration languages like JSON, maybe jq built-in? Much like pattern substitution and wildcards, a little bit of optimization here could go a long way.
  • A dependency framework or flexible type system like Why TypeScript for Infrastructure? You can include Makefiles, but the behavior is not very intuitive and difficult to debug. Although this might be one of the strengths of a Makefile.
  • File-watcher or live-reloading. You can create a build/deploy loop fairly easily, but having knowledge of the DAG simplifies this for developers. That's how skaffold works.