Background (you probably already know this part, but others reading this thread may not): CPython, along with most other flavours of Python, represents floats using the IEEE 754 binary64 format, and complex numbers as pairs of floats (one for the real part, one for the imaginary part). One of the oddities of IEEE 754 floating-point arithmetic formats is that they have two zeros: negative zero and positive zero, which for clarity I’ll try to remember to write as -0.0
and +0.0
in what follows (though I’ll inevitably forget some of the +
signs). The two zeros compare equal, and in most situations there’s no practical need to worry about the difference.
If you’re working with complex numbers but you don’t care about the signs of zeros in your real and imaginary parts, you can stop reading at this point. The “problem” being discussed here only arises when you start caring about those signs. (For some reasons why you might care about those signs in the context of complex arithmetic, take a look at Kahan’s “Much Ado About Nothing’s Sign Bit” paper.)
Assuming you’re still reading, try the following: enter 1.0 - 0j
into a Python prompt, and hit return. Here’s what you’ll see:
>>> 1.0 - 0j
(1+0j)
The intention is to produce a complex number with real part 1.0
and imaginary part -0.0
, but instead, as the repr indicates, we get a complex number with real part 1.0
(good!) and imaginary part +0.0
(what?!). This is essentially the problem under discussion.
So why do we get that +0.0
imaginary part? We’re subtracting a complex number (note that 0j
is already of type complex
, with real part +0.0
and imaginary part +0.0
) from a float (1.0
). Python first converts the float to type complex, then operates on the real parts and the imaginary parts separately. For the real part we get 1.0 - +0.0 = 1.0
. For the imaginary part, we get +0.0 - +0.0 = +0.0
(because that’s what IEEE 754 specifies for the usual roundTiesToEven rounding direction - actually, that’s the result we get for all rounding directions other than roundTowardNegative).
At this point it looks as though there’s an easy fix: let’s just special-case mixed-type float
and complex
addition and subtraction operations to not promote float
to complex
first - that way, we don’t have to invent an imaginary part for the float
operand. For example, if f
is a float
and z = complex(x, y)
is a complex number, we can evaluate f - z
as complex(f - x, -y)
, f + z
as complex(f + x, y)
, etc. For the particular case above, this would give us the expected result.
Unfortunately, this turns out to be only half a fix. Consider a slightly different case:
>>> (-0.0) + 1j
1j
Here the intended result is a complex number with real part -0.0
and imaginary part 1.0
. In this case it’s the real part that’s the problem: we get a real part of +0.0
where we were hoping for -0.0
.
And this time the root cause of the issue is slightly different from before. It’s not the promotion of float
to complex
that’s the problem - it’s that 1j
is already a complex number with real part +0.0
and imaginary part 1.0
. Now when we add the real parts to get the real part of the result, we’re doing (-0.0) + (+0.0)
, which again under IEEE 754 rules gives +0.0
.
Hence the proposed solution: if 1j
simply didn’t have a real part - if Python had an ‘imaginary’ type, and 1j
were an instance of that type - we could again special case the addition of a float
to an imaginary
to give the expected complex result.
It’s a fairly elegant solution, and it comes with a whole lot of other benefits, too (to take just one example, multiplication by 1j
has better properties, for example, so that multiplication by 1j
twice is exactly equal to negation, signed zeros and all, and so that x + y * 1j
also produces exactly complex(x, y)
).
I’ve never tried to push this suggestion for Python, for two main reasons: (a) it’s a fairly large and involved change in comparison to the size of the problem it’s solving - it feels like a case of “Purity Beats Practicality”, and (b) the WG14 C standards committee already tried this with C99 (see C99 §7.3.1p3 and C99 Annex G), adding optional imaginary types and a macro I
, with the intent that x + y * I
would indeed produce a complex number with real part x
and imaginary part y
(with nans, infinities, signed zeros all behaving as expected). But adoption of the C99 imaginary types has been disappointing: last time I checked, none of clang, gcc and MSVC implemented those types. I feel that if we were to try to introduce an imaginary type in Python, we should at least first try to understand why the major compilers weren’t interested in implementing these in C.
Addendum: I’ve concentrated on the problems with arithmetic operations above, but the other aspect that confuses people (and the issue that @skirpichev is focusing on) is the string representation. Consider:
>>> z = complex(-0.0, 1.0)
>>> z
(-0+1j)
Here the representation of z
that’s printed at the prompt is doing its best to indicate to the user that z
has real part -0.0
and imaginary part 1.0
. That part’s fine. The bad part is that the representation is a valid Python expression, and that when evaluated that Python expression doesn’t recover the original complex number exactly: the real part is now +0.0
rather than -0.0
:
>>> (-0+1j)
1j
@P403n1x87 Does the above help?