Should Programming Languages be Safe or Powerful?
Should a programming language be powerful and let a programmer do a lot, or should it be safe and protect the programmer from bad mistakes? Contrary to what the title insinuates, these are not diametrically opposed attributes. Nevertheless, this is the mindset that underlies notions such as, “macros, manual memory management, etc. are power tools—they’re not supposed to be safe.” If safety and power are not necessarily opposed, why does this notion persist? The problem—I think—is that historically you did have to trade safety for certain kinds of power: if you wanted to write a high-performance device driver, C—with all its unsafe behavior—was your only option. This founded the idea that the “power tools” of the industry were fundamentally dangerous. There’s a few things wrong with this though: Power is relative to the domain of interest. Both Haskell and C are powerful, but in completely different ways. So, when judging whether an aspect of a language is powerful or not, consider its application. Expressive languages get you power without sacrificing safety. New advances in programming language research have found ways to express problem domains more precisely. This means that we have less and less reason to breach safety and reach into the unsafe implementation details to get our work done. It’s good to add safety to power tools. A safe power tool is more trustworthy than an unsafe one. This holds for real-world tools: I will never use a table saw without a functioning saw stop. Specifically in the case of macros, there’s been an evolution from powerful-but-unsafe procedural macros in Lisp to safe-but-less-powerful pattern macros in Scheme, and finally to powerful-and-safe macros in Racket. More safety means higher reliability—something that everyone wants. And with advances in making languages more expressive, you can have a language perfectly suited to a particular domain without sacrificing safety. A language that lets you do more of what you want to do is more powerful than a language where you can’t do what you want. But what does “what you want to do” encompass? If you want to write device drivers, then C is great for you. However, C is not as expressive in some of the ways that, say, Haskell is. For example, in Haskell, I can write lazy, recursive definitions. Here’s a list of all Yes, all the Fibonacci numbers. Haskell is lazy; this will compute as many as you ask for. the Fibonacci numbers: Before you tell me that that’s just a useless cute trick, I actually had to use this when I was building the balancing algorithm in my rope data structure for my text editor written in Haskell . Haskell is incredibly powerful in an expressive sense: a single line of code can elegantly capture a complicated computation. The purpose of abstraction is not to be vague, but to create a new semantic level in which one can be absolutely precise. Edsgar Dijkstra Power is closely related to the domain of interest: a language is powerful in a particular realm of problems. C is powerful for working with memory directly. Conversely, Haskell or Racket is more powerful than C in pretty much every other domain because these languages give the user tremendous ability to match the program to the domain . This is a meta-power that sets high-level languages apart from lower-level ones. Safe languages can be just as powerful as their unsafe counterparts—in many cases, they are more powerful because the abstractions they create better fit the domain. Whenever a tradeoff between power and safety must be made, that is a sign that the language is not the right fit for the domain. Consider how immutability gives you local reasoning power . At one of my industry jobs, our codebase was a mixture of Ruby and Elixir. Both are safe languages, but Elixir is immutable. When I was working on some Elixir code, I could read: and I didn’t have to worry about getting modified in the call to . To understand the output of this function, I didn’t have to worry too much about the implementation of . In contrast, if you did the same sort of thing in Ruby: the method could do something sneaky like set name to if it didn’t exist. You might think, “well, just document that behavior.” Now I need to read the documentation of every function I encounter—I might as well go read the code to be sure the documentation isn’t out of date. Local reasoning means to understand what is passed, I don’t have to worry in the first place if will do somethig to the result of . In this case, I did have to understand what every method call did to understand the function. This made it harder to track down errors because I had to account for all the side effects that could happen at every method call. Certain things like immutability might seem constraining, but constraints can liberate you by allowing you to rely on particular behaviors. Elixir doesn’t let you modify things in-place, but you can rely on this, which makes understanding and composing code easier. Haskell forces you to express side-effects in the type system, but this lets you know that calling a function with a signature like won’t do any IO or throw an exception. Rust doesn’t have like in Java, but you know when you get a pointer, you can safely dereference it and you don’t have to do all the null checking that you have to do in Java. The evolution of syntax macros in Lisp, Scheme, and Racket provide an interesting real-world instance of how safety and power can start off as a trade-off, but with better language design, become complimentary. I don’t have the space here to do a deep dive into Lisp macros, but here’s the short of it: Lisp macros are just functions that receive code as data. This code is represented as nested lists of symbols. All a macro needs to do is return a new list of symbols that will be spliced right into the call site. The problem with this is that these macros are unhygienic : if I introduce a new variable, as I did with in , that is just a bare symbol that can be inadvertently captured producing unexpected output: This is very bad! To use a macro safely, you need to be sure that it’s not introducing variables that you might accidentally capture. Lisp provides a mechanism Lisp has a function called which makes a fresh symbol for you to use. Some other languages such as Julia have a function; is a poor substitute for proper hygiene. to avoid some of the pitfalls with variable capture, but that’s not the end of the danger. If I have a macro that expands to a call to a function, e.g. , I would expect this to be the in scope at the time I defined the macro. However, this might not be the case—a user might inadvertently redefine a function, and then the macro would not behave in the expected way. Scheme has a faculty called , which lets you define transformations between a pattern and a template: Rust’s form is essentially from Scheme, but a little fancier with some syntax classes like and such. This is safe; the examples from the Lisp run as expected: However, we’ve lost some of the power because we can only define transformations between templates. We can’t, for example, write a macro that does some deep inspection of the code and makes decisions on how to expand. Furthermore, there’s no way for us to intentionally break hygiene when we really want to. Racket resolves the dilemma between having to choose between powerful Lisp-like procedural macros, and safe Scheme-like hygienic macros by giving us fully hygienic procedural macros! I have another blog post discussing macros in Lisp, Scheme, and Racket and I go into some detail about the evolution of those macro systems. And if you want to dive deep into macro hygiene, see Matthew Butterick’s excellent explainer on Hygiene from his book Beautiful Racket . The upshot of it is that Racket uses a combination of features (scope sets, syntax objects, etc.) to give the user a richer way of specifying syntax than simple dumb lists of symbols. This avoids inadvertent variable capture as well as keeps function references lined up nicely. However, macros can still do arbitrary computation, which means that we’re not constrained in the way that the pattern-transformation macros in Scheme are. And just to prove that Racket is just as powerful as Common Lisp, here’s the classic macro: This example is inspired by Greg Hendershott’s fabulous tutorial Fear of Macros . The bit lets us introduce new bindings intentionally , whilst still keeping us from accidental breaches of macro hygiene. Consequentially, Racket’s macro system is far more useful than Lisp or Scheme’s systems, and this because of Racket’s safety and expressiveness. You can actually build trustworthy systems on top of Racket’s macro system because you’re not constantly foot-gunning yourself with hygiene malfunctions, and the macros are expressive enough to do some rather complicated things . Safe systems let us build software that is more capable and more reliable. Unsafe power is something to improve, not grudgingly accept—and much less defend as somehow desirable. Languages like Rust and Zig have made systems programming immune to whole hosts of errors by being more expressive than C, and languages like Racket are leading the way in making metaprogramming more useful reliable and less like dark magic. If you want to learn more about writing macros in Racket, check out Beautiful Racket by Matthew Butterick and Fear of Macros by Greg Hendershott. I highly recommend listening Runar Bjarnason’s talk at Scala World, Constraints Liberate, Liberties Constrain , wherein he discusses how constraining one part of a system can open up freedoms of later components that build on that constrained part. Power is relative to the domain of interest. Both Haskell and C are powerful, but in completely different ways. So, when judging whether an aspect of a language is powerful or not, consider its application. Expressive languages get you power without sacrificing safety. New advances in programming language research have found ways to express problem domains more precisely. This means that we have less and less reason to breach safety and reach into the unsafe implementation details to get our work done. It’s good to add safety to power tools. A safe power tool is more trustworthy than an unsafe one. This holds for real-world tools: I will never use a table saw without a functioning saw stop.