* and mul do the same thing, but __mul__ is different.
* and mul perform some checks before delegating to __mul__. There are two things that you should know about.
NotImplemented
There is a special singleton value NotImplemented that is returned by a class's __mul__ in cases where it cannot handle the other operand. This then tells Python to try __rmul__. If that fails too, then a generic TypeError is raised. If you use __mul__ directly, you won't get this logic. Observe:
class TestClass:
def __mul__(self, other):
return NotImplemented
TestClass() * 1
Output:
TypeError: unsupported operand type(s) for *: 'TestClass' and 'int'
Compare that with this:
TestClass().__mul__(1)
Output:
NotImplemented
This is why, in general, you should avoid calling the dunder (magic) methods directly: you bypass certain checks that Python does.
- Derived class operator handling
Where you attempt to perform something like Base() * Derived(), where Derived inherits from Base, you would expect Base.__mul__(Derived()) to be called first. This can pose problems, since Derived.__mul__ is more likely to know how to handle such situations.
Therefore, when you use *, Python checks whether the right operand's type is more derived than the left's, and if so, calls the right operand's __rmul__ method directly.
Observe:
class Base:
def __mul__(self, other):
print('base mul')
class Derived(Base):
def __rmul__(self, other):
print('derived rmul')
Base() * Derived()
Output:
derived rmul
Notice that even though Base.__mul__ does not return NotImplemented and can clearly handle an object of type Derived, Python doesn't even look at it first; it delegates to Derived.__rmul__ immediately.
For completeness, there is one difference between * and mul, in the context of pandas: mul is a function, and can therefore be passed around in a variable and used independently. For example:
import pandas as pd
pandas_mul = pd.DataFrame.mul
pandas_mul(pd.DataFrame([[1]]), pd.DataFrame([[2]]))
On the other hand, this will fail:
*(pd.DataFrame([[1]]), pd.DataFrame([[2]]))