Python Quiz Advent Calendar: explanations

(0 comments)

Here are the explanations for the Python Quiz Advent Calender puzzles which appear over December 2020.

1 December

Question

What is the output of the following:

a = 1 + 1, 2 + 1
assert a == 2, 3
Answer
AssertionError: 3
Explanation

In the first line, the variable name a is attached to the tuple object (2, 3) as probably intended. The assert statement, however takes the form:

assert <expression>[, <message>]

where the message to output upon an assertion failure is optional and separated from the expression to be tested by a comma. This means that to assert equality to a tuple requires the tuple to be defined with parentheses. As it stands, the assert tests to see if a is equal to 2 (which it isn't) and outputs 3 if the AssertionError is raised (which it is). What was probably intended was:

assert a == (2, 3)

2 December

flist = [lambda a: a**i for i in range(5)]
print(flist[2](3))

outputs 81 because of Python's late binding of closures: the variable i is resolved at the time the function is called, by which time it has the value 4. A closure is a function that makes reference to an object, such as i here, in an enclosing scope: Python's rules about how the value of such an object is determined say that rather than being copied across when the function is defined, a reference to the i object is kept and used when the function is called: there is only one i object and its final value (4) is the one that is used in the calculation $3^4 = 8$.

To force Python to use the intended value of i, a common approach is to pass it as a default argument to the lambda, forcing it to be bound to the function when it is defined:

flist = [lambda a, i=i: a**i for i in range(5)]
print(flist[2](3))

returns $3^2$ as 9.


3 December

The expession

1 and 2 or False

Comparisons can be chained in Python, so this is equivalent to:

1 and 2 and 2 or False

Furthermore, the boolean operations return the first object encountered in the boolean test satisfying its conditions, not (necessarily) just True or False. Thus, 1 and 2 evaluates to 2 (which is equivalent to True), and 2 or False also evaluates to 2. ~Therefore, 1 and 2 or False is equivalent to 2 and 2 and the returned value is 2.


4 December

Python's unary minus operator is allowed to be separated from its operand by whitespace; the statement a -=- 1 is equivalent to a -= -1 which is the same as a += 1.


5 December

Dictionary keys are considered the same if their hashes are the same and if their values are equal. The integer, float and complex values 1, 1.0, and (1+0j) are all the same according to this test; the string object '1.0' is distinct, as is the float resulting from the calculation (1-1.e-16). However, the limited precision of floating point arithmetic makes (1-1.e-17) indistinguishable from the integer 1 and it is used as such to become the key for the value 'f'. The assignment:

d = {1: 'a', 1.0: 'b', 1.+0j: 'c', '1.0': 'd', (1-1.e-16): 'e', (1-1.e-17): 'f'}

therefore results in the dictionary:

{1: 'f', '1.0': 'd', 0.9999999999999999: 'e'}

6 December

The difference in behaviour between 'augmented assignment', +=, using the special method __iadd__ and simple list extension with + is that the former changes the list object in place, and whereas + creates a new list object. Therefore,

a = [1, 2]
b = c = a
a += [3, 4]

extends the single list referred to by a, b, and c, and then

b = b + [5, 6]

creates a new list with the contents [1, 2, 3, 4, 5, 6] and binds it to the variable name b (which loses its reference to the previous list). We therefore end up with:

print(a, b, c, sep='\n')
[1, 2, 3, 4]
[1, 2, 3, 4, 5, 6]
[1, 2, 3, 4]

7 December

To parse the statement

x, y = x[y] = {}, 42

recall that chained assignments are evaluated sequentially. The statement is therefore equivalent to:

temp = {}, 42
x, y = temp
x[y] = temp

The dictionary x is assigned with the key 42 to a value which is a tuple, (x, y) of itself and the integer 42. The circular reference in x is indicated by {...}:

print(x, y)
{42: ({...}, 42)}, 42

8 December

The expression

{1 for i in range(5)}

generates a set from the a sequence of five 1s. Sets only contain unique values, so the object created is {1}.


9 December

This is another chained comparison:

3 > 2 == True

is equivalent to

3 > 2 and 2 == True

which evaluates to False.


10 December

a, b, c = 0.7, 0.2, 0.1
print(a + b + c == a + (b + c))

outputs False because the finite precision of floating point arithmetic means that addition of floating point numbers is not associative. In particular,

In [1]: a, b, c = 0.7, 0.2, 0.1
In [2]: a + b
Out[2]: 0.8999999999999999
In [3]: (a + b) + c
Out[3]: 0.9999999999999999

but

In [4]: b + c
Out [4]: 0.30000000000000004
In [5]: a + (b + c)
Out [5]: 1.0

The bitwise representations of these (double precision floating point numbers) illustrate the problem:

      a + b: 0011111111101100110011001100110011001100110011001100110011001100
          c: 0011111110111001100110011001100110011001100110011001100110011010
(a + b) + c: 0011111111101111111111111111111111111111111111111111111111111111

whereas:

          a: 0011111111100110011001100110011001100110011001100110011001100110
      b + c: 0011111111010011001100110011001100110011001100110011001100110100
a + (b + c): 0011111111110000000000000000000000000000000000000000000000000000

The actual calculation involved in floating point addition is well-described here.


11 December

In the code

for i in range(3):
    print(i)
    i = 3

the variable i receives a new value from the range object at each iteration, so the assignment to 3 is discarded at the start of each pass through the loop. The output is therefore

0
1
2

12 December

The statement

x: str = 4

defines a type hint on variable name x. impying that x is a str. This is valid syntax, but Python does not check that the type is as hinted: x is still assigned to the integer object 4.


13 December

In the floating point arithmetic used by Python, zero is a signed quantity. The modifier j creates a complex number consisting of floating point real and imaginary components, and the unary minus operator returns the negative of each of those components. That is, first 1j is evaluated to generate (0+1j), and the negative of this is (-0-1j).


14 December

In Python, the integer division a // b returns the floor of the division a/b: the largest integer not greater than a/b. As Guido van Rossum says in his blog this maintains the mathematically nice property that a/b = q with remainder r satisfies b*q + r = a and 0 <= r < b.

-22/7 is about -3.14, and the floor of this is -4.


15 December

The loop syntax can assign its iterated values into a mutable sequence, such as a list. In the program:

arr = [1, 2, 3]
for arr[0] in range(3):
    print(arr)

the first element of arr is changed (before it is printed) at each iteration to 0, 1, and then 2, giving:

[0, 2, 3]
[1, 2, 3]
[2, 2, 3]

16 December

The Python split function uses two different algorithms, depending on whether it is passed a seperator argument, sep, or not.

If sep is not specified (or is None), runs of consecutive whitespace are treated as a single separator: this is so that the result list will contain no empty strings at the start or end if the string has leading or trailing whitespace. This is usually the behaviour desired: ' a b c '.split() should return ['a', 'b', 'c'], not ['', 'a', 'b', 'c', '']. In the first example, therefore, ''.split()) is the empty list, [], with length0.

If sep is given, consecutive delimiters are not grouped together and are considered to delimit empty strings. This is so that, for example, splitting a comma-separated string with missing values behaves as expected: 'a,,c'.split(',') returns ['a', '', 'c'], not ['a', 'c']. In the case of splitting an empty string with a whitespace delimiter specified, the algorithm just returns that empty string in a list: [''], which has length 1.

If a string consisting of a single space is split on the space delimiter, the empty strings either side of it are returned: ' '.split(' ') returns ['', ''], a list of length 2.


17 December

sorted returns a sorted list of the provided iterable object. In this case, the iterable object is a tuple, however, which is not equal to this list, even though its elements are in the same order:

In [1]: a = 1, 2, 3
In [2]: a
Out[2]: (1, 2, 3)
In [3]: sorted(a)
Out[3]: [1, 2, 3]

Sorting the list twice is fine, and returns two equal lists:

In [4]: sorted(a), sorted(a)
Out[4]: ([1, 2, 3], [1, 2, 3])

18 December

reversed(a) returns an iterator which yields the contents of the sequence a in reverse order. It can be used once so the first test, sorted(a) == sorted(b) compares two lists of the sorted values in a, [1, 2, 3], and returns True. The second test now compares this sorted list with an empty list returned by sorting the, now exhausted iterator, b, and the result is False.


19 December

Generally, it is not a good idea to modify a sequence you are iterating over.

numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
for i in range(len(numbers)):
    del numbers[i]

In the above the first element of the list (1) is deleted on the first iteration, so that 2 becomes the new first element. On the second time round the loop, we delete numbers[1] which now points to the integer 3; next we delete the integer 5 which is now indexed at i=2, and so on until we have deleted the odd numbers and numbers has shrunk to [2, 4, 6, 8, 10]; the next attempt to index into this list with i=5 fails, and an IndexError is raised.

Note that the range object is created with the length of the original numbers list and not modified as this list is altered.


20 December

As the official documentation explains, an empty iterable passed to all will return True:

In [1]: all([])                                                              
Out[1]: True

In the second case, the iterable contains a single element, an empty list. The empty list is equivalent to False, and so:

In [2]: all([[]])                                                            
Out[2]: False

In the third case, the single element of the iterable passed to all is not empty: it contains a single element, namely another empty list. A non-empty list is equivalent to True (no matter what the list contains), and so:

In [3]: all([[[]]])                                                          
Out[3]: True

21 December

Unlike in C++, for example, there is no "inline" incrementor operator like ++ (augmented assignment is a single statement, e.g. a += 1). The expression a++4 is equivalent to a+(+4) and returns 7, since the value of a is 3. The "unary plus"(?) operator does nothing and is syntactically allowed for symmetry with unary minus: any number of +s are therefore allowed: a+++4 is the same as:

a + (+(+4))

which is also 7, of course.


22 December

Python tuples are immutable collections of references to other objects. Those objects can be mutable themselves (though the tuple won't be hashable if they are), as long as their identity doesn't change (i.e. the object reference remains the same). For example the following is fine:

In [1]: a = ([1],)
In [2]: a[0].extend([2, 3, 4])
In [3]: print(a)

([1, 2, 3, 4],)

Attempting the extend the list using the + operator (which calls the list's __add__ method) will fail:

In [4]: a = ([1],)
In [5]: a[0] = a[0] + [2, 3, 4]

...
TypeError: 'tuple' object does not support item assignment

In [6]: print(a)
([1],)

The expression a[0] + [2, 3, 4] returns a new list object, as can be seen from:

In [7]: b = [1]

In [8]: id(b)                                                              
Out[8]: 140200480518384

In [9]: b = b + [2, 3, 4]

In [10]: id(b)                                                              
Out[10]: 140200431232976

This new object cannot replace the existing element a[0] and an exception is raised when the assignment to the tuple (a[0] = ...) is attempted.

The augmented assignment, a[0] += [2, 3, 4] changes the list in-place, through its __iadd__ method: a is altered before the attempted assignment to the tuple. Therefore, once the erroneous change to an immutable object is detected and an exception raised, a already has its new contents.

More information from the official documentation and this blog article by Ned Batchelor.


23 December

The code

class A:
    food = 'eggs'

defines a class, A, with a class variable name, food, set to the string object eggs.

The two further code blocks

class B(A):
    pass

class C(A):
    pass

define classes, B and C, which inherit from A. At this point, food points to a single string object. Changing the class variable for the B class creates a reference to a new string object and does not change the value of A.food (which is the same as C.food):

B.food = 'spam'
print(A.food, B.food, C.food)

eggs spam eggs

Now changing the object that A.food points to will update this class variable value the classes A and C (which inherits from it), but not for B (which already has food bound to a separate object): this is usually the desired behaviour:

A.food = 'toast'
print(A.food, B.food, C.food)

toast spam toast

Internally, class variables are stored in a dictionary-like object and not copied across to inherited classes (instead, they are looked up in the inheritance chain). The vars built-in makes this clear:

In [1]: class A:
   ...:     food = 'eggs'
   ...:
   ...: class B(A):
   ...:     pass
   ...:
   ...: class C(A):
   ...:     pass
   ...:

In [2]: vars(A)     # contains the reference to food.
Out[2]:
mappingproxy({'__module__': '__main__',
              'food': 'eggs',
              '__dict__': <attribute '__dict__' of 'A' objects>,
              '__weakref__': <attribute '__weakref__' of 'A' objects>,
              '__doc__': None})

In [3]: vars(B)    # no reference to food: resolved in parent class, A
Out[3]: mappingproxy({'__module__': '__main__', '__doc__': None})

In [4]: vars(C)    # no reference to food: resolved in parent class, A
Out[4]: mappingproxy({'__module__': '__main__', '__doc__': None})

In [5]: B.food = 'spam'

In [6]: vars(B)    # new reference to food added to B
Out[6]: mappingproxy({'__module__': '__main__', '__doc__': None, 'food': 'spam'})

In [7]: A.food = 'toast'

In [8]: vars(A)    # reference to food  in A changed
Out[8]:
mappingproxy({'__module__': '__main__',
              'food': 'toast',
              '__dict__': <attribute '__dict__' of 'A' objects>,
              '__weakref__': <attribute '__weakref__' of 'A' objects>,
              '__doc__': None})

In [9]: vars(C)   # still no reference to food: looked-up in parent class A
Out[9]: mappingproxy({'__module__': '__main__', '__doc__': None})

24 December

def func(a=[]):
    a.append(10)
    return a

print(func(), func())

outputs:

[10, 10] [10, 10]

The default argument to the function func is the same list object every time the function is called. The arguments to the print function are evaluated before being printed, so calling func() twice leaves a with the contents [10, 10]. The above code is equivalent to:

lst = func()    # lst is [10]
lst = func()    # lst is [10, 10]
print(lst, lst)
Current rating: 5

Comments

Comments are pre-moderated. Please be patient and your comment will appear soon.

There are currently no comments

New Comment

required

required (not published)

optional

required