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 write Complex<double> a{1.2, 2.25}
.
- Instead of
a.real()
and a.imag()
, you would write a.real
and a.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;
}
std::complex
doesn't? \$\endgroup\$