Automatic type conversions¶
Magical Float¶
For our first bit, we’ll look at how to make classes act a little magically by using a relatively ignored operator
in the language. Like with all good things, lets start off with some code.
#include <iostream>
#include <type_traits>
#include <vector>
#include <string>
#include <memory>
#include <typeinfo>
struct RegularFloat
{
RegularFloat(float value) : _value(value), _multiplier(1) {};
RegularFloat(float value, float multiplier) : _value(value), _multiplier(multiplier) {}
float _value;
int32_t _multiplier;
};
struct MagicalFloat
{
// Ignore the stuff below this - we'll revisit it later so in more detail so lets skip it for now
MagicalFloat(float value) : _value(value), _multiplier(1) {};
MagicalFloat(float value, float multiplier) : _value(value), _multiplier(multiplier) {}
operator std::string() { return "[value: " + std::to_string(_value) + ", multi: " + std::to_string(_multiplier) + "]"; }
operator double() { return _value*_multiplier; }
operator std::unique_ptr<MagicalFloat>() { return std::make_unique<MagicalFloat>(MagicalFloat{_value}); }
float _value;
int32_t _multiplier;
};
In the above code block, we have two classes that look pretty similar: RegularFloat
and MagicalFloat
. The MagicalFloat
one is hidden for now because it’s better if we don’t look at it until later. They are similar in a lot of different dimensions. For example:
{
RegularFloat regular{2};
MagicalFloat magical{2};
std::cout << "They have the same size: regular is " << sizeof(regular) << " bits, and magical is " << sizeof(magical) << " bits" << std::endl;
std::cout << "They both have a _value member: regular._value = " << regular._value << ", and magical._value = " << magical._value << std::endl;
std::cout << "They both have a _multiplier member: regular._multiplier = " << regular._multiplier << ", and magical._value = " << magical._multiplier << std::endl;
}
They have the same size: regular is 8 bits, and magical is 8 bits
They both have a _value member: regular._value = 2, and magical._value = 2
They both have a _multiplier member: regular._multiplier = 1, and magical._value = 1
There are some differences though. These differences are what make MagicalFloat
so magically so lets take a look, shall we?
If we wanted to get the string representation of our regular
object, we’d need to do something like:
{
RegularFloat regular{2};
std::cout << "[value: " << std::to_string(regular._value) << ", multi: " << std::to_string(regular._multiplier) << "]" << std::endl;
}
[value: 2.000000, multi: 1]
And that works, but that was a lot of work. MagicalFloat
however isn’t like that. You can just get what you want out of it:
{
MagicalFloat magical{2};
std::string my_str = magical;
std::cout << my_str << std::endl;
}
[value: 2.000000, multi: 1]
Well that was convenient, wasn’t it? But that’s not all. Magic float can also deduce what you want out of it. Say we wanted to get the value
{
MagicalFloat magical{2, 2};
std::string my_str = magical;
double value_times_multiplier = magical;
std::cout << "Value of magical is: " << my_str << std::endl;
std::cout << "and value_times_multiplier is: " << std::to_string(value_times_multiplier) << std::endl;
}
Value of magical is: [value: 2.000000, multi: 2]
and value_times_multiplier is: 4.000000
Now you shoudl be wondering, “Wait a second! They assigned the same object to different types and got completely different things! How does that even make sense?”
// Its literally the same type!
std::string my_str = magical;
double value_times_multiplier = magical;
// If we did this with regular, it wouldn't even compile! You can't just turn a random object into a string and also a double!
And you’d be right. Normally, you can’t do this. So what makes MagicalFloat
so magical? Turns out that C++ provides us with the ability to override the operator
s with specific types - called “user-defined conversion functions”. If you want to read more, check out: https://en.cppreference.com/w/cpp/language/cast_operator
It’s really these 2 lines that are helping us create the magic:
operator std::string() { return "[value: " + std::to_string(_value) + ", multi: " + std::to_string(_multiplier) + "]"; }
operator double() { return _value*_multiplier; }
The syntax for this is just:
operator conversion-type-id { ... }
In the above example, we specified two converion types - std::string
and double
. So when the compiler sees that we are assigning out struct which would normally not at all be compatible / implicitly convertable to a std::string
or a double
now has an explicit means of doing so. And you can specify any conversion type you want. We included one for a unique_ptr
as well with:
operator std::unique_ptr<MagicalFloat>() { return std::make_unique<MagicalFloat>(MagicalFloat{_value}); }
To see it in action:
{
MagicalFloat magical{3, 4};
std::unique_ptr<MagicalFloat> unique_magical = magical;
std::cout << typeid(magical).name() << std::endl;
std::cout << typeid(unique_magical).name() << std::endl;
}
N11__cling_N5312MagicalFloatE
St10unique_ptrIN11__cling_N5312MagicalFloatESt14default_deleteIS1_EE
With great power comes great responsibility. Normally, we wouldn’t recommend any sane person do this in their code base. It can be confusion at times seeing a line like:
MagicalFloat magical{3, 4};
std::unique_ptr<MagicalFloat> unique_magical = magical;
and wondering how in the heck that can even compile. But perhaps you’ll find a justifiable reason to use this in a better setting!
One step deeper in the rabbit hole¶
It would be a shame if we just stopped there, so lets take one more step deeper. We can push this idea to accomplish something like the following:
#include <iostream>
#include <memory>
template <typename T>
struct DecayT
{
DecayT(T* t) : _this(t) {}
operator int() { return _this->get_int(); }
operator std::string() { return _this->get_str(); }
operator float() { return _this->get_float(); }
T* _this;
};
struct ConvertToAllTheTypes
{
auto get_float() { return _value; }
auto get_int() { return _value; }
auto get_str() { return std::to_string(_value); }
auto get() {
// type can be in here too! Just can't be templated
return DecayT{this};
}
float _value;
};
So in the above block, we have 2 types. Lets see what they can do:
{
ConvertToAllTheTypes my_type{123.456};
// So we can call into the usual type converters:
std::cout << "using the regular type:" << std::endl;
std::cout << "As a float: " << my_type.get_float() << std::endl;
std::cout << "As an int: " << my_type.get_int() << std::endl;
std::cout << "As a string: " << my_type.get_str() << std::endl;
// but we can also make it auto-magically
auto dream_type = my_type.get();
// and do the same thing as before, but with much more ease:
std::cout << "using the dream type:" << std::endl;
std::cout << "As a float: " << static_cast<int>(dream_type) << std::endl;
std::cout << "As an int: " << static_cast<std::string>(dream_type) << std::endl;
std::cout << "As a string: " << static_cast<float>(dream_type) << std::endl;
// We can also just as easily store these into variables:
int my_int = dream_type;
std::string my_str = dream_type;
float my_float = dream_type;
std::cout << "storing off into individual types:" << std::endl;
std::cout << "As a float: " << my_int << std::endl;
std::cout << "As an int: " << my_str << std::endl;
std::cout << "As a string: " << my_float << std::endl;
}
using the regular type:
As a float: 123.456
As an int: 123.456
As a string: 123.456001
using the dream type:
As a float: 123
As an int: 123.456001
As a string: 123.456
storing off into individual types:
As a float: 123
As an int: 123.456001
As a string: 123.456
I know we named the above a “Dream” type, but in all honestly, the above type is more gimmickal that useful. It’s just neat to be able to wrap any type into this convertable type that can be passed around or used to translate things without loading this functionality into the main object. But again, thread with caution!