--- title: 'Python Is Not an Acceptable ML' description: 'Balagopal Komarath argues that Python is a poor fit for introductory programming when compared with languages that catch more mistakes early.' date: '2022-11-28' author: name: 'Balagopal Komarath' image: '/favicon/web-app-manifest-192x192.png' tags: ['Programming Languages', 'Teaching', 'OCaml', 'Python'] coverImage: '/images/course-resources/theory-of-computing.png' --- Python is a popular fixture in introductory programming courses. Its adoption is mainly driven by two reasons: its popularity in scientific computing and industry, and its perceived readability. The first point is irrefutable. The second is more complicated. Python's surface-level readability does not compensate for its violations of several fundamental principles of programming-language and user-interface design. Many of the problems below are faced most sharply by beginning programmers. A language with better design, such as OCaml, avoids or even prevents several of them. When programming, errors can be detected at different stages. Some are caught by an editor or IDE while typing; some by a compiler; some always appear when the program runs; and some remain hidden until particular runtime conditions occur. A well-designed programming language should detect as many errors as possible as early as possible. Can we create a perfect language where all errors are compile-time errors? Such languages exist, but they usually require advanced knowledge to use well. Every language is designed around a tradeoff between correctness and usability. But the tradeoff is not linear. If we quantified usability and correctness on a scale of 1 to 100, some languages might be 80 in usability and 80 in correctness, while others might be 90 in usability and 50 in correctness. My thesis is that OCaml is an 80-usability, 80-correctness language for introductory programming, while Python is, at best, a 90-usability, 50-correctness language. The examples below explain why. ## Errors of Commission and Omission The following Python program has an error. Can you find it? ```python def sound(animal): if animal == 'dog': return 'bow' elif animal == 'cat': return 'meow' elif animal == 'cow': return 'moo' elif anima1 == 'pig': return 'oink' elif animal == 'human': return 'huh' print(sound('cat')) ``` It prints: ```text meow ``` The error is that one occurrence of `animal` is misspelled as `anima1`. Python accepts the program and the bug remains hidden unless that branch is reached. This is a conditional runtime error that should have been a compilation error. In OCaml, the same function can be written without even spelling out the parameter: ```ocaml let sound = function | "dog" -> "bow" | "cat" -> "meow" | "cow" -> "moo" | "pig" -> "oink" | "human" -> "huh" | _ -> assert false let () = print_endline (sound "cat") ``` The parameter name conveys no useful information here, so the language lets us avoid it. If we write the function in a more Python-like style and make the same spelling mistake, OCaml reports it immediately: ```ocaml let sound animal = if animal = "dog" then "bow" else if animal = "cat" then "meow" else if animal = "cow" then "moo" else if anima1 = "pig" then "oink" else if animal = "human" then "huh" else assert false ``` ```text Line 5, characters 10-16: 5 | else if anima1 = "pig" then "oink" ^^^^^^ Error: Unbound value anima1 Hint: Did you mean animal? ``` The same issue appears when programs evolve. Suppose we write two Python functions: ```python def sound(animal): if animal == 'dog': return 'bow' elif animal == 'cat': return 'meow' elif animal == 'caterpillar': return '...' def legs(animal): if animal == 'dog' or animal == 'cat': return 4 elif animal == 'caterpillar': return 1000 ``` Later, we decide to handle humans: ```python def sound(animal): if animal == 'dog': return 'bow' elif animal == 'cat': return 'meow' elif animal == 'caterpillar': return '...' elif animal == 'human': return 'huh' ``` If we forget to update `legs`, then `legs('human')` returns `None`. Python gives no warning. The error remains conditional, because it appears only if that function is called with that input. In OCaml, the natural representation uses a sum type: ```ocaml type animal = Cat | Dog | Caterpillar let sound = function | Cat -> "meow" | Dog -> "bow" | Caterpillar -> "..." let legs = function | Cat | Dog -> 4 | Caterpillar -> 1000 ``` If we add `Human` to the type but forget to update `legs`, the compiler points out the omission: ```ocaml type animal = Cat | Dog | Caterpillar | Human let legs = function | Cat | Dog -> 4 | Caterpillar -> 1000 ``` ```text Warning 8 [partial-match]: this pattern-matching is not exhaustive. Here is an example of a case that is not matched: Human ``` ## What Is in a Name? The scope of a name defines the context in which it is valid. Python's rules for scope are often unintuitive: ```python day = 'Monday' def setday(newday): day = newday setday('Tuesday') print(day) ``` This prints: ```text Monday ``` The `day` inside `setday` refers to a newly created local variable, not the global `day`. Python implicitly creates variables on first assignment in functions, avoiding an explicit keyword like `let` or `var`. This violates Python's own principle that explicit is better than implicit. The problem is not limited to global variables: ```python def end(s): last = "x" def a(): last = "a" def b(): last = "b" for c in s: if c == "a": a() elif c == "b": b() return last print(end("abracadabra")) ``` This prints: ```text x ``` The assignments inside `a()` and `b()` do not affect the `last` in the scope of `end()`. Python's fix is to use `global` and `nonlocal` declarations. But it is easy for a beginner to forget them, producing conditional runtime errors. ## Python UI Lies A fundamental rule of user-interface design, including programming-language design, is that things that look the same should behave the same. Consider: ```python x = 5 y = x x = x + 1 print(x, y) x = [] y = x x.append(0) print(x, y) ``` The output is: ```text 6 5 [0] [0] ``` Changing `x` also changes `y` in the second case but not in the first. Python provides a consistent-looking interface to value types and reference types, even though they behave fundamentally differently. The list replication operator makes the same issue worse: ```python xs = [[0] * 3] * 3 xs[0][0] = 1 print(xs) ``` The result is: ```text [[1, 0, 0], [1, 0, 0], [1, 0, 0]] ``` The rows are not independent lists. The expression creates repeated references to the same inner list. Implicit duplicate references can also appear during iteration: ```python xs = [1, 2, 3, 4] for x in xs: if x % 2 == 1: xs.remove(x) print(xs) ``` This seems to work: ```text [2, 4] ``` But a small change exposes the problem: ```python xs = [1, 2, 3, 4] for x in xs: xs.remove(x) print(xs) ``` The result is still: ```text [2, 4] ``` The iterator keeps an implicit reference to the list, while the loop body mutates it through `xs`. The language allows the conflict. ## Python Non-Functionality Higher-order programming, the ability to manipulate functions as values, is important because it enables code reuse beyond first-order abstractions. Python has adopted many higher-order features from the ML family, but its inability to distinguish value and reference types weakens them. Consider: ```python def dup(x): return (x, x) def applyfst(f, pair): (x, y) = pair return (f(x), y) ``` With integers, this behaves as expected: ```python print(applyfst(lambda x: x + 1, dup(0))) ``` ```text (1, 0) ``` With lists, it does not: ```python def append0(xs): xs.append(0) return xs print(applyfst(append0, dup([]))) ``` ```text ([0], [0]) ``` The definitions of `dup` and `applyfst` are logical, but their behavior changes depending on whether they are used with values or references. In a real program, such functions may come from a library. A user should not need to know the implementation details of a library to use it safely. The OCaml equivalent has no such surprise: ```ocaml let dup x = (x, x) let applyfst f (x, y) = (f x, y) let inc x = x + 1 let () = assert ( applyfst inc (dup 0) = (1, 0) ) let append0 xs = xs @ [0] let () = assert ( applyfst append0 (dup []) = ([0], []) ) ``` Even Python's built-in higher-order functions must be used with care: ```python def listmap(f, xs): return list(map(f, xs)) print( listmap( lambda f: f(0), [lambda x: x + 1, lambda x: x + 2] ) ) print( listmap( lambda f: f(0), [lambda x: x + i for i in range(1, 10)] ) ) ``` The output is: ```text [1, 2] [9, 9, 9, 9, 9, 9, 9, 9, 9] ``` It is possible to teach students to avoid such errors by explaining Python's abstract machine. But the point of a high-level language is to raise the machine's level of conversation toward the human, not to lower the human's level of conversation toward the machine. In OCaml, the corresponding code works as expected: ```ocaml let apply fs x = List.map (fun f -> f x) fs let rec range n m = if n = m then [n] else n :: range (n + 1) m let fs = List.map (fun i -> (+) i) (range 1 9) let () = assert (apply fs 0 = range 1 9) ``` ## OCaml Imperativity There is at least one place where Python is usually considered more usable: mutation and iteration. The following computes a factorial using a mutable variable: ```python def factorial(n): p = 1 for i in range(2, n + 1): p = p * i return p ``` The classic recursive OCaml version mirrors the mathematical definition: ```ocaml let rec factorial n = if n = 0 then 1 else n * factorial (n - 1) ``` Functional-programming experts would usually write a tail-recursive version for performance: ```ocaml let factorial n = let rec loop acc = function | 0 -> acc | n -> loop (acc * n) (n - 1) in loop 1 n ``` But OCaml is not as strict about functional style as some functional languages. We can mirror the Python implementation: ```ocaml let factorial n = let p = ref 1 in for i = 2 to n do p := !p * i done; !p ``` The difference is that we must explicitly state that `p` is mutable by making it a `ref`. The `!` operator retrieves the current contents of the reference. This may look less pretty than Python, but it satisfies the principle that explicit is better than implicit. ## Fixing Python? Realistically, it would be difficult to convince people to switch from Python to OCaml. If Python is used with learners, I suggest these guidelines: - For misspellings and type changes, introduce automated tests early. Teach function syntax before control structures. Mandate linters such as `flake8`, unit testing with `pytest`, and type hints from the beginning. - Avoid global variables and local variables accessed by nested functions as much as possible. - Treat lists and dictionaries as either immutable, or ensure there is exactly one active reference to them at all times. - Introduce higher-order programming anyway, because it is important. It may be better to let learners encounter Python-specific problems as they arise rather than turning an introductory course into a course on Python arcana. ## Epilogue There are many scenarios where Python may be a better choice than OCaml. This article considers only the suitability of a language for introductory programming courses. The switch from C to Python helped a larger number of students get into programming. A switch from Python to a better-designed language could have a similar effect in the future.