2
\$\begingroup\$

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;
}
\$\endgroup\$
2
  • \$\begingroup\$ What does this do that std::complex doesn't? \$\endgroup\$ Sep 2, 2022 at 15:20
  • \$\begingroup\$ @TobySpeight e.g., mixed complex types arithmetic \$\endgroup\$
    – panik
    Sep 2, 2022 at 16:21

2 Answers 2

1
\$\begingroup\$

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;
};
  1. Instead of Complex<double> a(1.2, 2.25), you would write Complex<double> a{1.2, 2.25}.
  2. 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;
}
\$\endgroup\$
7
  • 1
    \$\begingroup\$ requires is the modern, more readable alternative to std::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 the arithmetic_type concept yourself (it's not provided in standard library). \$\endgroup\$ Sep 2, 2022 at 16:26
  • 1
    \$\begingroup\$ I believe, const references are redundant in the constructor. Probably, other references may be removed too because Complex wraps only two arithmetic variables. \$\endgroup\$
    – panik
    Sep 2, 2022 at 16:26
  • 1
    \$\begingroup\$ @Mark, member functions real() and imag() are consistent with std::complex, so there's an argument for keeping the familiar interface. \$\endgroup\$ 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
    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 think static_assert works here because it stops compilation on a bad type and prints a useful error message. \$\endgroup\$
    – Mark H
    Sep 2, 2022 at 16:47
1
\$\begingroup\$

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;
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.