Python is Like Assembly
Python and Assembly have one thing in common: as a professional software engineer, they are both languages that you probably should know how to read, but be terrified to write. These languages seem to be (and are) at opposite ends of the spectrum: One is almost machine code, and the other is almost a scripting language. One is beginner-friendly and the other is seen as hostile to experts. One is viciously versatile with tons of libraries and ports, and the other is ridiculously limited in its capabilities. However, when you are creating production software, both are the wrong tool for the job.
Python: Easy to Write, Hard to Read
You probably agree with me that you shouldn’t be writing assembly code, but Python is much more contentious. Many people argue that Python is bad to use in production because it is slow, and that you will get better performance using something else. However, in most of the cases where I have seen Python make it to production, Python is not slow: the Python code is often gluing together some NumPy or TensorFlow functions, which are written in optimized C. Python users who care a lot about speed and need to run actual Python can reach for Cython or Pyston to solve their speed problems. The problem with Python isn’t its speed.
The problem is that Python is a language that is optimized for writeability. Dynamic typing, playing it fast and loose with scoping, and syntactic whitespace are all nice features when you want to churn out a lot of code quickly. Having every error show up a runtime is not a big deal if you are expecting to be the person running the code you wrote. For that reason, it is a very popular language: you can feel very “productive” when you are churning out Python. However, if you are working on a large codebase or an old codebase, wortking with Python is a huge headache, especially when you need the code to run 24/7 at thousands of QPS.
Type information is immensely helpful to readers and maintainers, and guarantees about what type a variable can hold solve a lot of headaches. Many Python programs are not written with a lot of type discipline: variables can often hold many different types, and when you modify a program, you need to consider all possible types. Also, it can often be unclear how to access certain variables due to the odd scoping. This is actually pretty similar to assembly code: a Python variable is like an architectural register - it’s hard to know what’s inside without reading a lot of code.
An added consequence of all of the dynamism is that programs don’t really know what a variable is and how to manipulate it until runtime, making it very hard to write helpful IDEs and analyzers similar to what you get with C++, Rust, Go, Java, or almost any other language. Those runtime errors also have a nasty habit of showing up only once code gets to production even when they would ordinarily be caught by a tool in another language.
For small code snippets, one-offs, and pseudocode, Python is an invaluable tool. For production, reach for something else.
Assembly Code doesn’t do its Job
When people think of high-performance code, they often think of hand-optimized assembly. In limited circumstances, this is true. Nobody has ever argued that assembly code is readable or easy to maintain, so for our purposes, I am going to focus on the performance arguments, and why you should almost never reach for assembly when performance is critical.
More often than not, writing assembly language (and even C code) can cause you to miss algorithmic solutions to speed problems in favor of brute micro-optimization. Here are a few examples to try in C and assembly:
- Write a function that produces the sum of every number from 1 to 10000
- Write a function that divides a number by 57
For the first example, you probably wrote a loop in both languages. However, if you plug your C code into compiler explorer, you will find that clang will give you a closed-form solution when you compile it in C, resulting in a huge speedup. For the second example, you may have used a DIV instruction, or you may have known that you can do this with a MUL and a magic number. If you used a DIV instruction, clang will beat you again: it can compute the magic number and turn that into a multiplication. If you used a MUL instruction, congratulations on knowing the trick, but you had to compute the magic number yourself, didn’t you? More subtly, hand tuning will often result in bad register allocation and code that doesn’t play well with other code.
Also, there are a few huge readability problems with assembly code. One of these problems is that variables tend to exist as registers and memory locations, which can be ephemeral. The contents of a register are not immediately obvious without considering the previous code. Macros can go a long way toward code readability, but they do not take you all the way to the point where you have variable and function names names. This rhymes with one of the reasons why Python is hard to maintain: it’s hard to know what your variables actually could be, and there is no verification of their state.
This is why you don’t want to write assembly code directly: doing the work of a compiler yourself is hard, and the compiler has a good global view. In cases where you might want to reach for assembly to do something that is not native to a higher-level language, like computing a CRC32C or a carryless multiplication, there is almost always an intrinsic function you can use, so you never have to accept writing assembly.
Fully optimized assembly is UGLY, as it should be in order to go fast, but ugly code is hard to work with. Instead of writing (slow) human-readable assembly, let the compiler do the work. For critical sections, read the output of the assembler and tweak with intrinsics and hints when the compiler doesn’t find the right code. There is almost nothing that needs fully handwritten assembly these days, but there is still a lot of assembly that needs to be read.
Readability is Paramount
All long-running software projects have one thing in common: the code is read a lot more times than it is written. When you decide what language to use on a project, readability should be a key factor in any piece of code that is more than a one-off. In other words, the main audience for the code you write is not a computer, but other programmers.
Python is a fantastic scripting language and a decent language for prototyping new applications, but unfortunately, it is a language designed to be written, not read. Like assembly languages, Python is a conversation between a programmer and a computer, and is ill-equipped for communication with other programmers at scale and over time.
If you must use Python in production, there are lots of attempts to improve the readability of Python by adding static typing and type annotations. These tools will likely be valuable, but they do not get you all the way. For now, I will continue to use Python as a scripting language and a tool for small one-offs, but I recommend that projects planning for longevity or significant scale start in something else.