I have created a class Complex
which represents a complex number of any arbitrary arithmetic type. Overloads are given for the 4 common functions, addition, subtraction, multiplication, and division.
The overloads take two different template arguments so as to allow operating on complex numbers of different types together, and the decltype(Ty_a() + Ty_b())
is used to automatically determine the return type from the template parameters. How could I improve this code?
#include <type_traits>
#include <iostream>
template<
typename Ty,
typename std::enable_if<std::is_arithmetic<Ty>::value, int>::type = 0
>
class Complex
{
public:
Complex() = default;
Complex(const Ty &r, const Ty &i) noexcept :
r(r), i(i)
{}
Ty real() const noexcept {return r;}
Ty &real() noexcept {return r;}
Ty imag() const noexcept {return i;}
Ty &imag() noexcept {return i;}
private:
Ty r, i;
};
template<typename Ty_a, typename Ty_b>
Complex<decltype(Ty_a() + Ty_b())> operator+ (const Complex<Ty_a> &a, const Complex<Ty_b> &b) noexcept
{
return {a.real() + b.real(), a.imag() + b.imag()};
}
template<typename Ty_a, typename Ty_b>
Complex<decltype(Ty_a() - Ty_b())> operator- (const Complex<Ty_a> &a, const Complex<Ty_b> &b) noexcept
{
return {a.real() - b.real(), a.imag() - b.imag()};
}
template<typename Ty_a, typename Ty_b>
Complex<decltype(Ty_a() * Ty_b())> operator* (const Complex<Ty_a> &a, const Complex<Ty_b> &b) noexcept
{
typedef decltype(Ty_a() * Ty_b()) result_t;
result_t real = a.real() * b.real() - a.imag() * b.imag();
result_t imag = a.real() * b.imag() + b.real() * a.imag();
return {real, imag};
}
template<typename Ty_a, typename Ty_b>
Complex<decltype(Ty_a() / Ty_b())> operator/ (const Complex<Ty_a> &a, const Complex<Ty_b> &b) noexcept
{
typedef decltype(Ty_a() / Ty_b()) result_t;
result_t denominator = b.real() * b.real() + b.imag() * b.imag();
result_t real = (a.real() * b.real() + a.imag() * b.imag()) / denominator;
result_t imag = (a.imag() * b.real() - a.real() * b.imag()) / denominator;
return {real, imag};
}
template<typename Ty>
std::ostream &operator<< (std::ostream &stream, const Complex<Ty> &num)
{
return stream << num.real() << '+' << num.imag() << 'i';
}
int main(int argc, char **argv)
{
Complex<double> a(1.2, 2.25);
Complex<int> b(2, -1);
Complex<double> y = a / b;
std::cout << y << std::endl;
}
2 Answers 2
Is encapsulation useful?
There's an important question to ask about this class: is there any benefit to keeping the data members private? As a thought experiment, how would the following struct
be used differently than what you wrote?
template<
typename Ty,
typename std::enable_if<std::is_arithmetic<Ty>::value, int>::type = 0
>
struct Complex
{
Ty real, imag;
};
- Instead of
Complex<double> a(1.2, 2.25)
, you would writeComplex<double> a{1.2, 2.25}
. - Instead of
a.real()
anda.imag()
, you would writea.real
anda.imag
.
In your class, the methods Ty Complex::real() const
and Ty& Complex::real()
(likewise for imag()
) give complete, unrestricted access to the underlying variable. I am not saying this is a bad design. If you want the users of this class to freely modify the real and imaginary parts of the complex number, then what you wrote is correct. However, what I wrote above achieves the same result with much less code.
Effectively, you are encapsulating the innards of your Complex
class by making the data members private and then breaking encapsulation by providing the non-const
reference methods. Using a struct
is simpler and achieves the same effect.
Making encapsulation useful
There's another way forward: make the class immutable by deleting all non-const
methods.
template<
typename Ty,
typename std::enable_if<std::is_arithmetic<Ty>::value, int>::type = 0
>
class Complex
{
public:
Complex() = default;
Complex(const Ty &r, const Ty &i) noexcept :
r(r), i(i)
{}
Ty real() const noexcept {return r;}
Ty imag() const noexcept {return i;}
private:
Ty r, i;
};
With this class, once a Complex
number is created, it can never be modified. Immutable data types are easy to reason about since you never have to worry about the value of a variable changing. If the user needs a modified version of a Complex
number, they can create a new one. Instead of
auto z = Complex(1, 1);
// ... more code ...
z.imag() = 2;
users can write
auto z = Complex(1, 1);
// ... more code ...
auto z2 = Complex(z.real(), 2);
One could argue that the second better expresses the intent of the programmer: "I want a Complex
value with the real part of this other Complex
number and an imaginary part equal to 2."
Which path you take depends on how you want the class to be used. As with most engineering questions, the correct answer is, "It depends."
Default Constructor
Your default constructor will leave both r
and i
uninitialized for the simple types of Ty
--int
, double
, etc. I would delete the default constructor to force users to create a valid value for every constructed Complex
number.
Templating
You've probably noticed how long the lines containing template functions can get. That's why there are helper functions for getting the ::type
and ::value
results. std::enable_if_t<>
is equivalent to std::enable_if<>::type
and std::is_arithmetic_v<>
is equivalent to std::is_arithmetic<>::value
.
template<
typename Ty,
typename std::enable_if_t<std::is_arithmetic_v<Ty>, int> = 0
>
Although, I would state the requirement more directly using static_assert
.
template<typename Ty>
class Complex
{
static_assert(std::is_arithmetic_v<Ty>, "Complex requires an arithmetic type.");
public:
// etc.
};
Type juggling
In your operator overloads, you specify the return value as Complex<decltype(Ty_a() + Ty_b())>
and similary for the other operations. A simpler name for the return type uses std::common_type
.
template<typename Ty_a, typename Ty_b>
Complex<std::common_type_t<Ty_a, Ty_b>> operator+ (const Complex<Ty_a>& a, const Complex<Ty_b>& b) noexcept
{
return {a.real() + b.real(), a.imag() + b.imag()};
}
Now, that's a little unwieldy to repeat for all the operations, so you can declare it once and reuse it.
template<typename Ty_a, typename Ty_b>
using op_return_type = Complex<std::common_type_t<Ty_a, Ty_b>>;
template<typename Ty_a, typename Ty_b>
op_return_type<Ty_a, Ty_b> operator+ (const Complex<Ty_a>& a, const Complex<Ty_b>& b) noexcept
{
return {a.real() + b.real(), a.imag() + b.imag()};
}
Using auto
to make life easier
You can use the auto
keyword to let the compiler figure out what types should be.
template<typename Ty_a, typename Ty_b>
op_return_type<Ty_a, Ty_b> operator/ (const Complex<Ty_a>& a, const Complex<Ty_b>& b) noexcept
{
auto denominator = b.real() * b.real() + b.imag() * b.imag();
auto real = (a.real() * b.real() + a.imag() * b.imag()) / denominator;
auto imag = (a.imag() * b.real() - a.real() * b.imag()) / denominator;
return {real, imag};
}
Putting everything together
Here's what you code looks like using all of my suggestions (using the immutable option for Complex
):
#include <type_traits>
#include <iostream>
template<typename Ty>
class Complex
{
static_assert(std::is_arithmetic_v<Ty>, "Complex requires an arithmetic type.");
public:
Complex(const Ty& r, const Ty& i) noexcept :
r(r), i(i)
{}
Ty real() const noexcept { return r; }
Ty imag() const noexcept { return i; }
private:
Ty r, i;
};
template<typename Ty_a, typename Ty_b>
using op_return_type = Complex<std::common_type_t<Ty_a, Ty_b>>;
template<typename Ty_a, typename Ty_b>
op_return_type<Ty_a, Ty_b> operator+ (const Complex<Ty_a>& a, const Complex<Ty_b>& b) noexcept
{
return {a.real() + b.real(), a.imag() + b.imag()};
}
template<typename Ty_a, typename Ty_b>
op_return_type<Ty_a, Ty_b> operator- (const Complex<Ty_a>& a, const Complex<Ty_b>& b) noexcept
{
return {a.real() - b.real(), a.imag() - b.imag()};
}
template<typename Ty_a, typename Ty_b>
op_return_type<Ty_a, Ty_b> operator* (const Complex<Ty_a>& a, const Complex<Ty_b>& b) noexcept
{
auto real = a.real() * b.real() - a.imag() * b.imag();
auto imag = a.real() * b.imag() + b.real() * a.imag();
return {real, imag};
}
template<typename Ty_a, typename Ty_b>
op_return_type<Ty_a, Ty_b> operator/ (const Complex<Ty_a>& a, const Complex<Ty_b>& b) noexcept
{
auto denominator = b.real() * b.real() + b.imag() * b.imag();
auto real = (a.real() * b.real() + a.imag() * b.imag()) / denominator;
auto imag = (a.imag() * b.real() - a.real() * b.imag()) / denominator;
return {real, imag};
}
template<typename Ty>
std::ostream& operator<< (std::ostream& stream, const Complex<Ty>& num)
{
return stream << num.real() << '+' << num.imag() << 'i';
}
int main(int argc, char** argv)
{
Complex<double> a(1.2, 2.25);
Complex<int> b(2, -1);
Complex<double> y = a / b;
std::cout << y << std::endl;
}
-
1\$\begingroup\$
requires
is the modern, more readable alternative tostd::enable_if
.static_assert()
is different, because it doesn't stop the function existing (which can be important with overloads). For the template itself, the short form is even simpler (template<arithmetic_type T>
) if you're willing to define thearithmetic_type
concept yourself (it's not provided in standard library). \$\endgroup\$Toby Speight– Toby Speight2022年09月02日 16:26:41 +00:00Commented Sep 2, 2022 at 16:26 -
1\$\begingroup\$ I believe,
const
references are redundant in the constructor. Probably, other references may be removed too becauseComplex
wraps only two arithmetic variables. \$\endgroup\$panik– panik2022年09月02日 16:26:44 +00:00Commented Sep 2, 2022 at 16:26 -
1\$\begingroup\$ @Mark, member functions
real()
andimag()
are consistent withstd::complex
, so there's an argument for keeping the familiar interface. \$\endgroup\$Toby Speight– Toby Speight2022年09月02日 16:33:21 +00:00Commented Sep 2, 2022 at 16:33 -
1\$\begingroup\$ @finlaymorrison Encapsulation is a very good thing. Hiding the nitty-gritty details of a class simplifies the code that uses that class. The point of the first two sections was to get you to think about how ill-chosen methods can break encapsulation and how to either fix it (the immutable class) or do without it (the struct). I prefer the immutable class version. \$\endgroup\$Mark H– Mark H2022年09月02日 16:45:05 +00:00Commented Sep 2, 2022 at 16:45
-
1\$\begingroup\$ @TobySpeight That's true about
requires
. I wasn't sure what version of C++ OP was using, so I stuck with C++11 constructs. I thinkstatic_assert
works here because it stops compilation on a bad type and prints a useful error message. \$\endgroup\$Mark H– Mark H2022年09月02日 16:47:24 +00:00Commented Sep 2, 2022 at 16:47
std::is_arithmetic<>
might be too restrictive. This prevents making complex numbers of bignum types, for example.
It's probably a good idea to define a Concept that requires
the arithmetic operations you want to support, and use that. Something like (untested)
template<typename T>
concept arithmetic_for_complex =
requires(T a, T b) {
a + b;
a * b;
/* etc. */
};
template<arithmetic_for_complex Ty>
class Complex;
Explore related questions
See similar questions with these tags.
std::complex
doesn't? \$\endgroup\$