My (Least) Favorite Python Quirks
Tags: python
Published: 2021-02-03 18:08:37

A couple language ergonomics issues that have given me trouble in my few years of writing Python.

## Declarations are executed

This is one that makes sense after you think about what's happening, but I think it's easy to get wrong the first time.

One example is the following:

```py
class MyClass:
  def __init__(self, data={}):
    self._data = data

  def set(self, k, v):
    self._data[k] = v

  def get(self, k):
    return self._data.get(d)

  def remove(self, k):
    self._data.pop(k, None)

  def get_count(self):
    return len(self._data.keys())

A = MyClass()
A.get_count() # 0
A.set('a', 1)
A.get_count() # 1

B = MyClass()
B.get_count() # 1

A.remove('a')
A.get_count() # 0
B.get_count() # 0
```

If you experimented further, you would see that the contents of `_data` is being somehow kept in sync between both instances of `MyClass`.

But interestingly:

```py
C = MyClass({})
C.get_count() # 0
```

What happened is that Python gets class and function definitions by executing a file line by line, which means that the default value of `data` in the `__init__` method is actually instantiated. All instances of the class that use the default value mutate and read from the same object, effectively making it a class variable.

This is confirmed by:

```py
A._data is B._data # True
```

Avoiding this involves doing something obtuse like:

```py
class MyClass:
  def __init__(self, data=None):
    if data is None:
      self._data = {}
    else:
      self._data = data

...
```

## Scope of loop variables

Unlike many languages, "loop variables" in Python stay in the local scope after the loop is exited. This can lead to some unexpected behavior:

```py
def fun():
  i = 0
  arr = [1, 2, 3]

  # lots of code

  for i in range(len(arr)):
    # do some stuff

  # more code

  return arr[i]

fun() # returns 3
```

While this example is pretty contrived, the general idea is that this behavior of loop variables can lead to weird and hard-to-track-down bugs (including this example of shadowing previously declared local variables).

Another complication: if there's nothing to loop over, loop variables won't be assigned at all. For example the first line of `main()` below runs without error but the second line will cause `NameError: name 'x' is not defined` to be raised.

```py
def fun(l):
  for x in l:
    print(x)
  return x

def main():
  fun([1, 2, 3])
  fun([])
```

Even other interpreted and non-curly-brace languages avoid these troubles, e.g. Ruby:

```ruby
3.times do |x|
  print x
end

x # NameError (undefined local variable or method `x' for main:Object)
```

## Function chaining is hard

When it comes to working with data structures, Python's provided functions and methods aren't very consistent. Take lists for example: the `List` class has methods that change it in-place, but for many operations you'll need to use list comprehensions which return new lists and have a unique syntax. For other operations you might want to use `filter` and `map`, which are functions (not methods of the list class). And there are other scattered inconsistencies, for example the fact that `join`, a function for working with lists, is a string method instead of a list method.

As an example:
```py
def slugify(string, bad_words):
  words = string.split()
  words = [w.lower() for w in words if w.isalpha() and w not in bad_words]
  return "-".join(words)

slugify("My test 1 string", set(["dang", "heck"])) # "my-test-string"
```

For people familiar with Python this probably looks natural, but even still the code requires careful reading to understand each transformation the function is doing (and this is for a simple example).

In other languages this kind of code is more natural. Ruby for example has more consistent methods that allow for chaining:

```ruby
def slugify(string, bad_words)
  string.split()
    .map(&:downcase)
    .select { |w| w.match(/^[[:alpha:]]+$/) }
    .select { |w| !bad_words.include? w }
    .join("-")
end

slugify("My dang test 1 string", Set["dang", "heck"]) # "my-test-string"
```

As an example from the functional world, Clojure strongly supports function chaining with consistent APIs and the threading macros `->` and `->>`.
```clojure
(defn slugify [string bad-words]
  (->> (str/split string #" ")
    (filter #(re-matches #"^[a-zA-Z]*$" %))
    (remove bad-words)
    (map str/lower-case)
    (str/join "-")
  )
)

(slugify "My dang test 1 string" (set '("dang" "heck"))) ; "my-test-string"
```