but in fact any
we can now see that if
For complex roots, that is the exponent atan2(z.imag, z.real)
. This results in a branch cut for complex powers along the line
In most (if not all) programming languages, the result of sqrt(complex(-1.0, 0.0))
will be different to sqrt(complex(-1.0, -0.0))
, because of this branch cut. Mathematically, this is not a problem and is not incorrect, but when it appears as part of complicated decomposition code, these large-magnitude changes can cause huge cascading effects, causing entirely different decompositions to be chosen. The resulting decompositions are also valid, but it certainly can cause us headaches while trying to refactor numerical code!
If it’s really desired, we can use one of the tricks of IEEE-754 floats that stymies optimising compilers to normalise floating-point zeros to positive branchlessly. IEEE-754 defines x = x + 0.0
leaves all regular values of x
completely in tact, but negative zeroes are made positive.
Some languages have a literal syntax for working with complex numbers:
j
suffix on numeric literals; i
suffix on numeric literals; im
built-in variable in conjunction with its juxtaposition rules for implicit multiplication, so 4im
is interpreted as 4 * im
. _Complex_I
, which is exactly equivalent to Julia’s im
, but C has no implicit multiplication by juxtaposition so you do 2.0 * _Complex_I
1. i
suffix in std::complex_literals
that is functionally equivalent to Python’s j
. Notably, all of these methods produce numbers of the form complex(0.0, b)
; they all start with zero real part. These languages all allow interoperation between different numeric types, via different mechanisms, so expressions such as 1.0 + 2.0j
(Python) or 1.0 + 2.0im
(Julia) both produce valid complex numbers.
Python and Ruby both promote numeric values of different types to a common type before performing arithmetic operations. This means that evaluation of the expression 1.0 + 2.0j
is evaluated identically to add(complex(1.0, 0.0), complex(0.0, 2.0))
. The expression is not a single complex-number literal, but instead, the real component 1.0
is promoted to a complex
, then the two components are added together with the rule complex(a.real + b.real, a.imag + b.imag)
.
C, C++ and Julia behave differently to Python and Ruby. All three often promote to a common type before arithmetic operations, but not entirely if one operand is a real type and the other is a complex. C defines its “usual arithmetic conversions” (C99 §6.3.1.8) as finding a “common real type” (not a “common type”), then addition is performed with the values without having promoted any real to a complex. C++ and Julia have similar behaviour for (at least) the addition and subtraction operators.
It’s easiest to see this behaviour in Julia’s standard library. It doesn’t use its convert
and promote
system to effect addition between reals and complexes, but instead uses its multiple-dispatch system to overload +(::Real, ::Complex)
(and vice versa) to avoid the initial promotion:
+(x::Real, z::Complex) = Complex(x + real(z), imag(z))
+(z::Complex, x::Real) = Complex(x + real(z), imag(z))
and similar for -
(but with some extra trickery to avoid x::Bool
causing trouble).
This approach may feel the same as Python’s and Ruby’s. What’s hiding, though, is that Julia’s imaginary components are directly imag(z)
, whereas in Python and Ruby they would be imag(z) + 0.0
. As we saw previously, in floating-point arithmetic, x + 0.0
is not necessarily the same float as x
; it normalises negative zero to positive zero.
These rules are why we end up with:
$ python -q
>>> 1.0-0.0j
(1+0j)
>>> complex(1.0, -0.0)
(1-0j)
$ irb
irb(main):001:0> 1.0-0.0i
=> (1.0+0.0i)
irb(main):002:0> Complex(1.0, -0.0)
=> (1.0-0.0i)
$ julia -q
julia> 1.0-0.0im
1.0 - 0.0im
Note that in both Python and Ruby’s case, complex(1.0, 0.0) - complex(0.0, 0.0)
give the same result as the literal version, but in Julia if we explicitly use a promotion or conversion form of subtraction, we lose the signed zero, and get the same behaviour as Python or Ruby:
julia> -(promote(1.0, 0.0im)...)
1.0 + 0.0im
julia> convert(Complex, 1.0) - 0.0im
1.0 + 0.0im
For completeness’ sake, a C form:
#include <complex.h>
#include <stdio.h>
int main(int argc, const char *argv[])
{
double _Complex z = 1.0 - 0.0*_Complex_I;
printf("(%g, %g)\n", creal(z), cimag(z));
return 0;
}
$ gcc-13 -std=c99 complex.c -o complex
$ ./complex
(1, -0)
The Julia (and C/C++) behaviour is perhaps the less surprising at the end of the day, since adding some real number to a complex value doesn’t feel like it should affect the imaginary component. The unfortunately knock-on effect, though, is that promoting the real value to a complex and then adding it also feels like it should have the same behaviour, but in the latter case we run afoul of signed zeroes, and the former skips them.
We actually usually use I
in C99 which is usually exactly the same as _Complex_I
. C also describes an optional _Imaginary
type in its Annex G, though, which has yet another set of rules, and if this is implemented, then I
is defined to be _Imaginary_I
instead. In practice, neither GCC nor Clang implement Annex G, though some other compilers now do; Annex G was lifted from “informative” status in C99 to “normative” in C11, but remained optional to actually implement. ↩