Skip to content

# Wat’s up, doc?

Published: October 13, 2015. Filed under: Pedantics, Programming, Python.

In much the same vein as Gary Bernhardt’s wonderful talk about JavaScript, there’s a collection of Python “wat” moments which goes around every so often. There’s also an associated quiz linked from that page (which I won’t spoil; you can read through it yourself and then check your answers). Every language has some unintuitive — or at least seemingly-unintuitive — bits, and Python is no exception. But if you’re working in Python, understanding why (or, perhaps more appropriately, wy) these snippets of code behave the way they do is interesting and potentially useful (OK, probably not useful, but at least it’s interesting). So let’s take a look at them and see what’s really going on.

### “Converting to a string and back”

The given example is:

```>>> bool(str(False))
True
```

This one’s pretty straightforward: `str(False)` is `"False"`, and `bool("False")` is `True`, because any non-empty string is `True` (“truthy”, if you want to be precise, since Python boolean checks rarely use the actual `bool` instances).

### “Mixing integers with strings”

The example:

```>>> int(2 * 3)
6
>>> int(2 * '3')
33
>>> int('2' * 3)
222
```

This one’s a little more interesting, and causes people to argue about Python’s type system. The behavior here comes from the fact that Python supports operator overloading, and doesn’t restrict what types you’re allowed to define your operators on. In this case, the `*` operator is implemented on the numeric types, where it is a multiplication operator (and, obviously, requires the other operand to be numeric). But it’s also implemented on the sequence types (remember, `str` is a sequence type in Python), where it’s a repetition operator and requires the other operand to be numeric.

So when given this operator with one operand that’s numeric and another operand that’s a sequence, Python applies the repetition behavior.

### “The undocumented converse implication operator”

Fun times:

```>>> False ** False == True
True
>>> False ** True == False
True
>>> True ** False == True
True
>>> True ** True == True
True
```

Understanding this one requires a bit of trivia about Python’s history. Originally there was no built-in boolean type and so (as in plenty of other languages which lack booleans) the convention was to use integer `1` as the “true” value and integer `0` as the “false” value. Python 2.2.1 introduced `bool()` as a built-in function but no boolean type — rather, it defined `True` and `False` as built-in aliases for `1` and `0`. The `bool()` function would return `1` for “truthy” values and `0` for “false-y” values. Python 2.3 implemented the `bool` type, as a subclass of `int` with only two instances: `True` and `False`, which had integer values `1` and `0`. This has stuck around into the Python 3 line, as you can verify for yourself:

```\$ python
Python 3.5.0 (default, Sep 26 2015, 18:41:42)
[GCC 4.2.1 Compatible Apple LLVM 6.1.0 (clang-602.0.53)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> issubclass(bool, int)
True
>>> isinstance(True, int)
True
>>> isinstance(False, int)
True
>>> True + True
2
>>> True - False
1
```

For more details about the weirdness surrounding the introduction of the `bool` type, see this blog post from Guido.

### “Mixing numerical types”

```>>> x = (1 << 53) + 1
>>> x + 1.0 < x
True
```

The author says “Note: this is not simply due to floating-point imprecision.” Which is technically true, I guess, but slightly misleading: the trick here is pushing past the range in which a double-precision float can represent every integer (the 53-bit shift gives it away, as double-precision floats have only 53 bits of precision in the significand). If you toy with it, you’ll find that you only get even numbers past that point, as expected for this range: in IEEE 754, from `2**51` to `2**52`, double-precision floats are spaced by 0.5, switching to spaced by 1 — i.e., all integers and only integers can be represented — up to `2**53`, and beyond `2**53` they’re spaced 2 apart so only even integers can be represented.

### “Operater precedence?”

```>>> False == False in [False]
True
```

This isn’t exactly about precedence; rather, it’s about Python’s support for chained comparison operators. We’re used to being able to do things like `if x < y <= z` in Python, and we’re used to it doing the right thing with constructs like that. That chain of operators is equivalent to `if (x < y) and (y <= z)`, but with `y` only being evaluated once.

And since `==` and `in` are comparison operators, the same holds here: `False == False in [False]` is equivalent to `(False == False) and (False in [False])`. Both comparisons are true, so the overall result is true.

### “Iterable types in comparisons”

```>>> a = [0, 0]
>>> (x, y) = a
>>> (x, y) == a
False

>>> [1,2,3] == sorted([1,2,3])
True
>>> (1,2,3) == sorted((1,2,3))
False
```

This one is a bit of a reach. What’s really going on in the first example is that `a` is a list, and `(x, y)` is a tuple. A list and a tuple will not compare equal, even if their contents are identical. Similarly, `sorted()` returns a list, so you’ll only get a successful equality comparison when comparing the result to a list.

### “Types of arithmetic operations”

```>>> type(1) == type(-1)
True
>>> 1 ** 1 == 1 ** -1
True
>>> type(1 ** 1) == type(1 ** -1)
False
```

Python allows arithmetic comparisons of floats and ints to work, so `1 == 1.0` (and `1**-1` is `1.0` — negative exponents always return a float). But `int` and `float` are not the same type, so the type-equality check fails.

### “Fun with iterators”

```>>> a = 2, 1, 3
>>> sorted(a) == sorted(a)
True
>>> reversed(a) == reversed(a)
False

>>> b = reversed(a)
>>> sorted(b) == sorted(b)
False
```

This is fun with types again. Python’s `sorted()` built-in takes a sequence, and returns a list containing the same values sorted. But `reversed()` returns an iterator object which will traverse the sequence in reverse order.

The iterator returned by `reversed()` does not implement the `__eq__()` method, so for equality comparisons Python falls back to calling `__hash__()` on each operand and comparing the results. The iterator also doesn’t implement `__hash__()`, so it gets the default implementation from `object`, which in turn is derived from the memory address of the object. Since two different iterator instances have different memory addresses, the results of two calls to `reversed()` on the same sequence will compare unequal.

The comparison of results of `sorted()` in the second example is trickier: the first call to `sorted()` consumes the iterator returned by `reversed()`, and produces the sorted list `[1, 2, 3]`. But the second call to `sorted()` has nothing left to consume, and returns the empty list `[]`, and it is the case that `[1, 2, 3] != []`.

### “Circular types”

```>>> isinstance(object, type)
True
>>> isinstance(type, object)
True
```

This is just one of those things :)

### “extend vs +=”

```>>> a = ([],)
>>> a.extend()
>>> a

>>> a += 
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
>>> a
[1, 2]
```

Python won’t let you directly assign to indices in a tuple, either via the normal or augmented (`+=` and friends) syntax. But it will let you call methods of the objects in the tuple, and if those objects happen to be mutable and happen to define methods which let you mutate them without using assignment syntax, it’ll work.

### “Indexing with floats”

```>>> 
4
>>> [0.0]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: list indices must be integers, not float
>>> {0:4}
4
>>> {0:4}[0.0]
4
```

This is slightly sneaky: the first two examples use a list, and list indices must be integers. The second two examples use a dictionary, and any hashable type can serve as a dictionary key.

As to why 0 and 0.0 return the same value, I’m not 100% certain of this (as I haven’t looked at the CPython dictionary implementation lately), but I believe the collision-avoidance allows two keys to get the same value from the dictionary if they have the same hash and compare equal (and since `hash(0) == hash(0.0)` and `0 == 0.0` you get the result in the example).

### “all and emptiness”

```>>> all([])
True
>>> all([[]])
False
>>> all([[[]]])
True
```

Tricky. The argument to `all()` is a sequence. So in the first example, we’re asking it to evaluate an empty sequence; `all()` is defined to return `True` for an empty sequence. The second one gets a sequence containing one item — an empty list — which evaluates `False`, so returns `False`. The third one gets a sequence containing one item — a list containing an empty list — which evaluates `True` (because the list containing the empty list is itself nonempty), and so returns `True`.

### “sum and strings”

```>>> sum("")
0
>>> sum("", ())
()
>>> sum("", [])
[]
>>> sum("", {})
{}
>>> sum("", "")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: sum() can't sum strings [use ''.join(seq) instead]
```

This is another one where a quick look at the documentation for the function reveals what’s going on.

When given an empty sequence, `sum()` will return `0`, and the empty string is an empty sequence. When given two arguments, `sum()` treats the second argument as a starting accumulator value to return when the supplied sequence is empty (in fact, the definition of it is `sum(sequence, start=0)`, so really in the empty-sequence case with one argument it’s just returning the default value of `start`); that’s what’s going on in the second, third and fourth examples. In the fifth example, `sum()` complains that it can’t work with a string value for the second argument, since `sum()` is defined as able to reject non-numeric types.

There is some wartiness here, though; `sum()` only type-checks its second argument (if you want to verify, it’s `builtin_sum()` on Python 2, and `builtin_sum_impl()` on Python 3, and in either version is located in `Python/bltinmodule.c` in the source tree). On Python 2, it short-circuits to a `TypeError` if the second argument is an instance of `basestring`; on Python 3, it short-circuits to `TypeError` when the second argument is an instance of `str`, `bytes` or `bytearray`.

But it never checks the type of the first argument, or of the items in that argument (if it’s a sequence); it simply relies on the fact that iteration on a non-sequence will raise `TypeError`, and addition of a string to an integer will raise `TypeError` (the latter because you can’t pass a string value to the second argument, and that argument defaults to `0` when not specified).

### “Comparing NaNs”

```>>> x = 0*1e400  # nan
>>> len({x, x, float(x), float(x), 0*1e400, 0*1e400})
3
>>> len({x, float(x), 0*1e400})
2
```

`NaN` is weird. IEEE 754 tells us that comparisons with `NaN` are unordered; `NaN` is neither greater than, less than nor equal to any floating-point value, including itself.

So in the first `len()` call, in theory we should expect an answer of 6; all the values are `NaN` and none of them are equal to any of the others, so the set literal shouldn’t prune out any “duplicate” values. Similarly, the second `len()` call should return 3.

What actually seems to be happening is that Python is considering `x` and `x` to be “duplicate” values, `float(x)` and `float(x)` to be “duplicate” values, and `0*1e400` and `0*1e400` to be “distinct” values. Why that is I’m not quite sure. I suppose it’s possible there’s some sort of tricky single evaluation thing going on, but that would require Python to know that `float(x)` always returns the same value for the same `x` (and in this case it’s not true in the sense that the calls both return `NaN` values that compare unequal).

Edit: a comment on reddit hits on the solution. Python does appear to be using identity as an optimized short-circuit to avoid doing a potentially-expensive equality check. And indeed, `x is x` and `float(x) is float(x)` both return `True` with `x = 0*1e400`, but `0*1e400 is 0*1e400` returns `False`. If someone else wants to have a bit of fun, take a look into why `0*1e400 is not 0*1e400`.

I’d call this one a wart, but I’m also willing to just call `NaN` itself, and probably IEEE 754, a wart (it’s just that it’s less warty than the alternatives).