This article was written in September 2020. It is a short report on some interesting parts of the CPython interpreter, written for the “Computer Architecture Theory 2” course at my university. The writing may be a bit messy. If you are interested, please read on. :)
Summary
This report mainly uses source-code reading and code experiments to understand CPython’s internal implementation of comparison operators and identity operators.
I found that comparison operators such as == and < are processed as COMPARE_OP bytecode. CPython tries the tp_richcompare slots of the operand types according to a defined priority; for user-defined classes, these slots invoke special methods such as __eq__. If neither operand provides a result, == and != fall back to an identity comparison. For ease of use, I have organized a list of the built-in types’ richcmp methods. I will provide more details and describe the analysis process in the article.
Finally, I provide a simple analysis of the caching mechanism for small numbers in CPython’s int type. I found that CPython caches integer objects in the range [-5, 256]. The analysis appears in the section <Other interesting findings>.
Introduction
Python has a pair of identity operators: is and is not.
They can be used to determine whether two references point to the same object. In CPython, this is implemented by comparing object pointers, as shown below:
| |
However, the following behavior may seem unintuitive and confusing:
| |
Since identity operators do not exist in many other languages, many beginners easily confuse is with the == operator. Many learning materials do not explain the distinction thoroughly, so I wanted to study the implementation principles.
This analysis is based on the latest CPython 3.10 development version from the master branch as of August 20, 2020. I found that its implementation differs considerably from the stable CPython 3.8 release available at the time, so please pay attention to the version difference. Now let’s move on to the main topic.
Analysis process
First, let’s pull and compile the latest source code. In the Python shell, we can use the dis module to inspect the CPython bytecode for is and ==:
| |
As we can see from the bytecode, in the latest 3.10 development version, is uses the IS_OP execution path (oparg = 0), while == uses the COMPARE_OP execution path (oparg = 2).
is operator
Following this clue, I searched the source code for IS_OP and found the following code in Python/ceval.c:
| |
We can see that the core logic of the IS_OP operator is very simple. In CPython, all Python objects are represented by PyObject, so the left operand, left, and the right operand, right, are pointers. The is operator compares these two pointers. If they are equal, it returns Py_True; otherwise, it returns Py_False.
== operator
What about the == operator? I searched the source code for COMPARE_OP and found the following code in Python/ceval.c:
| |
The code calls PyObject_RichCompare, passing the left operand as left, the right operand as right, and the rich-comparison opcode (for example, Py_EQ) as oparg. Let’s examine the source code of this function, which is located in Objects/object.c:
| |
The main body of this function performs safety checks. The most important step is the call to do_richcompare, so let’s continue:
| |
This function is relatively long, so let’s analyze it part by part.
Rich comparison
This function uses tp_richcompare and the constants Py_EQ and Py_NE. We can find the relevant code in Include/object.h:
| |
CPython types expose behavior through slots in PyTypeObject; this mechanism predates Python 3.8. By searching the Python C API documentation, we can find the special methods that correspond to tp_richcompare.
PyTypeObject Slot Type special methods/attrs Info tp_richcomparerichcmpfunc__lt__, __le__, __eq__, __ne__, __gt__, __ge__X G
Now look at the documentation for tp_richcompare:
1PyObject *tp_richcompare(PyObject *self, PyObject *other, int op);The first parameter is guaranteed to be an instance of the type that is defined by
PyTypeObject.The function should return the result of the comparison (usually
Py_TrueorPy_False). If the comparison is undefined, it must returnPy_NotImplemented, if another error occurred it must returnNULLand set an exception condition.
I organized this information into the following table:
| Operator | Special method | Opcode |
|---|---|---|
< | __lt__ | Py_LT = 0 |
<= | __le__ | Py_LE = 1 |
== | __eq__ | Py_EQ = 2 |
!= | __ne__ | Py_NE = 3 |
> | __gt__ | Py_GT = 4 |
>= | __ge__ | Py_GE = 5 |
We can override any one or more of the above methods to define the behavior of the corresponding operators.
Each object in Python is associated with a type. The type contains a tp_richcompare function pointer that determines the behavior of rich comparisons between objects.
By calling the tp_richcompare function for a given type, CPython runs the corresponding comparison logic. For user-defined classes, this generally invokes special methods written in Python.
For example, you can define the __eq__ special method in a custom class. The following example shows which method is called by a == comparison:
| |
The built-in types usually have __eq__ and __ne__. Some built-in types implement all the comparison operations, including __lt__. We can see this by running some simple code:
| |
Many of these special methods are implemented in C. For example, CPython uses the long_richcompare() function for the int type. I will list several implementations in the Conclusion.
The int expression x < 2 is faster than the explicit call x.__lt__(2), but not because it avoids rich-comparison dispatch. Both ultimately use the int comparison implementation in C; the explicit call also performs attribute lookup and method-call handling. Compare the times below.
| |
Next, let’s examine the three if statements in the do_richcompare function. They represent three cases.
The first case
| |
v and w are of different types, and w’s class is a strict subclass of v’s class. If w’s type has a non-NULL tp_richcompare slot, that slot is tried first. If it returns Py_NotImplemented, comparison continues with v. Here is an example:
| |
The second case
| |
If v’s type has a non-NULL tp_richcompare slot, that slot is tried after the optional subclass-priority check above. If it returns Py_NotImplemented, comparison continues with w. Here is an example:
| |
The third case
| |
If w was not already tried as a subclass and v’s comparison returned Py_NotImplemented (or v had no comparison slot), then w’s tp_richcompare slot is tried with the operands and operator swapped. For example:
| |
Other situations
Next, the function reaches a switch branch:
| |
If neither operand’s comparison slot returns a result, the function reaches the switch branch. For == and !=, it compares pointers; for an ordering operator, it raises TypeError.
User-defined classes inherit object_richcompare by default, but not every type has the same default slot. The switch branch is reached only when the available slots return Py_NotImplemented, or when the relevant slots are NULL.
We can use the following experiment to verify this behavior.
| |
The default implementation of richcompare
Why can we compare instances of class A and class B with == and != even though we have not defined __eq__ or __ne__, while the other comparison operators do not work? I found the relevant code in Objects/typeobject.c:
| |
So far, we know that instances of user-defined classes inherit the built-in object_richcompare function by default. By looking at this function, we can see that Py_EQ and Py_NE are implemented by default:
| |
We can see that case Py_EQ performs a simple pointer comparison. It returns Py_True when the pointers are the same and Py_NotImplemented otherwise. If it returns Py_NotImplemented, comparison continues according to the dispatch priority.
Note that in the Py_NE case, the function tries Py_EQ if tp_richcompare has been implemented:
| |
This means that if Py_NE has not been overridden, the function tries the Py_EQ case. If the result is true, it returns Py_False; otherwise, it returns Py_True.
Rules of do_richcompare
After the above analysis and experiments, I believe that you have a very clear understanding of its implementation. Let me organize a table of rules below.
Python do_richcompare(v, w, op)
| Priority | Condition | Do |
|---|---|---|
| 0 | v and w have different types, and w’s class is a strict subclass of v’s class | Try Py_TYPE(w)->tp_richcompare with the operands and operator swapped |
| 1 | v has a non-NULL slot | Try Py_TYPE(v)->tp_richcompare |
| 2 | w was not tried at priority 0 and has a non-NULL slot | Try Py_TYPE(w)->tp_richcompare with the operands and operator swapped |
| 3 | Every available slot returned Py_NotImplemented | Use the switch-branch fallback |
Other interesting findings
Now we have a clear understanding of how Python’s is and == operators are implemented. But do you still remember the surprising code at the beginning?
| |
They are both integers, so is there an essential difference between 1 and 114514? Let’s perform an experiment:
| |
From the output, we can see that a is b is True for the inclusive interval [-5, 256], but False for the other numbers in this experiment. This is related to the implementation of Python’s int type. In CPython 3, all int objects are represented as PyLongObject, regardless of their magnitude.
Let’s read Objects/longobject.c. In the _PyLong_Init function, a small_ints array is initialized for the interpreter:
| |
Now look at the definition of this array:
| |
Its size is _PY_NSMALLNEGINTS + _PY_NSMALLPOSINTS, and its range is [-5, 257).
| |
One C API path for creating an int is the PyLong_FromLong function, so let’s take a look at it:
| |
This function uses the IS_SMALL_INT macro to determine whether the number is in the range [-5, 256]. If it is, the function calls get_small_int and returns the result.
| |
Looking at the get_small_int function, we can see that numbers in this range already exist in small_ints, so CPython does not allocate a new object for them.
| |
At this point, the surprising behavior has been explained: CPython caches the numbers in small_ints and reuses those objects instead of allocating new ones.
| |
It can be seen that this is indeed the case.
However, literal-based tests can also be affected by compiler constant reuse. For example, two occurrences of 257 in the same compiled code block may refer to the same constant object. The string-to-integer experiment above avoids that particular source of interference. Small-integer caching and constant reuse are CPython implementation details, so application code should always use == for numeric value comparison.
Conclusion
This study analyzed the differences and connections between is and ==. In general:
iscompares object identity. In CPython, it does so by comparing the two object pointers.==uses rich comparison. It falls back to pointer identity only if neither operand’s comparison implementation provides a result.
Python’s commonly used built-in types such as int, str, list, and dict provide type-specific implementations of tp_richcompare.
I searched the source code and found the following examples. This list may be incomplete.
| Python type | Richcmp method |
|---|---|
| array.array | array_richcompare |
| bytearray | bytearray_richcompare |
| bytes | bytes_richcompare |
| cell | cell_richcompare |
| code | code_richcompare |
| collections.deque | deque_richcompare |
| collections.OrderedDict | odict_richcompare |
| complex | complex_richcompare |
| datetime.timezone | timezone_richcompare |
| dict | dict_richcompare |
| dict_items | dictview_richcompare |
| dict_keys | dictview_richcompare |
| float | float_richcompare |
| frozenset | set_richcompare |
| instancemethod | instancemethod_richcompare |
| int | long_richcompare |
| list | list_richcompare |
| mappingproxy | mappingproxy_richcompare |
| method | method_richcompare |
| method-wrapper | wrapper_richcompare |
| range | range_richcompare |
| re.Pattern | pattern_richcompare |
| set | set_richcompare |
| slice | slice_richcompare |
| str | PyUnicode_RichCompare |
| tuple | tuplerichcompare |
| weakref | weakref_richcompare |
The Python language has accelerated my development cycle. Personally, I like Python very much, but I had not previously delved into many of its details. After reading the CPython source code, I gained a better understanding of many behaviors that I had once found surprising.