Click here to Skip to main content
15,867,686 members
Articles / Programming Languages / C++
Article

number_cast<To, From> and Named Smart Casts

Rate me:
Please Sign up or sign in to vote.
5.00/5 (7 votes)
6 May 2022CPOL14 min read 4.5K   69   3   2
A more descriptive and tightly scoped cast for numeric conversions. Includes rounding options, overflow checks, a high resilience to coding errors and some special syntactical conveniences.
number_cast is strongly typed to specific numerical conversions with options for rounding during conversions, conversion overflow checking and a high resilience to coding errors. It can be used as a generic verbose casting function, in the same manner as static_cast. Or specific commonly used conversions can be aliased to a descriptive name whose correct application has the uncluttered appearance and the syntactical flexibility of a C style cast.

Contents

Background

The background to this article is the previously published “Smart numeric casts to end the agony of (int)... or static_cast<int>(...)” and in particular, its Background section. That article introduces the 'smart cast' design concept, but leaves the user to design code and name the casts they want to use whereas this article turns that design concept into a product ready for use. As the previous article remains as a reference, what follows here is a slightly different perspective on the same thing that gets more directly to the point .

In C++, the built in numeric types come with built in implicit casts between each and all the others. This allows code to be written that seamlessly moves values between any of the numeric types with no explicit mention of any conversions. So why do we use explicit numeric type casts at all?

It is for two reasons:

  • We want to make those seams visible, especially if they contain hazards or signify moving into a hazardous area – sometimes we will get compiler warnings if we don't.
  • We sometimes need conversions in places that do not have the cues to invoke an implicit conversion.

Neither standard C style casts nor static_cast serve that purpose very well. static_cast will stop you from accidentally converting a pointer – that is the only difference between them. They fall short because they don't specify, nor do they enforce, the specific conversion intended.

If I want a conversion from size_t to int, then I just write just (int) arg.

If I want a conversion from double to int, then I also write just (int) arg.

I knew the exact conversions I wanted when I wrote them but I just wrote (int). I could have told it but it wouldn't let me. The next time I look at it I may not remember and of course it can't enforce my intention because I never made it clear. The result is that these casts do not provide a verification point in your code, do not inform you and can produce results that were not what you expected.

Modern robust C++ depends heavily on clear declarations that tell programmers what is going on and allow the compiler to check correctness. We need something better than this. It needs to specify a specific conversion and enforce it.

Introduction

number_cast is exclusively for conversions between built in numeric types. It represents a specific conversion that is fully specified in its declaration (destination type, source_type and rounding option): number_cast<To, From, bRound = false>. Note that the destination type comes first.

It can be used directly like a verbose 'C++ style' cast :

C++
int x = number_cast<int, size_t>(a_size_t); //creates an int from a size_t

or specific conversions can be aliased to a descriptive name:

C++
using to_signed = number_cast<int, size_t>; 

which can then can be used like a 'C style' cast but describing the nature of the conversion rather than the destination type.

C++
int x = to_signed (a_size_t);

//and also
int x = (to_signed) a_size_t;

These are what I refer to in the previous article as 'smart casts' because they look like C style raw casts and have the same syntactical flexibility, but in reality are something much more sophisticated.

In this case (to_signed) shouts loud enough, says why it is there and is minimally intrusive on code that is really busy trying to do other things.

Conversion Overflows

number_cast comes with built in conversion overflow checking. The appropriate checks are automatically selected and built in during compilation according to the types being converted. This is activated by default in debug builds. In release builds, number_cast adds zero overhead apart from any rounding if specified.

N.B. Boost::numeric_cast also does this but doesn't share other features. Its existence is the reason I have settled for the more naïve sounding number_cast as a name.

Type Safety

Most importantly, it is very tightly scoped and will prevent misleading code from being compiled. In particular, the variable being converted must match the input type and the variable to which the result is assigned must match the output type. Whereas a standard cast and static_cast don't put constraints on either which can result in typos not being caught and a mess of invisible conversions. e.g.

C++
int res = static_cast<char>(a_double);         //compiles with implicit conversion

The static_cast will convert the double to a char because it doesn't define a specific input type, but then that char will be implicitly converted to an int. Did you really want to mangle any number bigger than 128 before assigning it to an int, or could there be a typo somewhere?

number_cast won't allow this:

C++
int res = number_cast<char, double>(a_double); //error will not compileç

Because of the above insistence that the destination type matches the declared output of the number_cast, it will not compile if the destination type is ambiguous.

C++
auto x = to_signed(a_size_t) ;  //won't compile – ambiguous destination type.

The requirement to know and verify the destination type can be relaxed by applying the () operator to the cast.

C++
auto x = to_signed(a_size_t)(); //ok

although in this case, it would have been more sensible to write ;

C++
int x = to_signed(a_size_t) ; 

Ambiguity can also occur when casting directly to a construction, assignment or function call that has multiple overloads for different numeric types. In many cases, the correct solution will be to remove the number_cast and let those multiple overloads do the conversion instead. Where that doesn't work, applying the () operator will allow the conversion to proceed.

Further Resilience to Coding Errors

number_cast is strictly a numerical value cast:

It will not specialise if there is any intrusion of * or & in its declaration.

C++
using to_signed = number_cast<int&, size_t>;  // error cannot specialise  number_cast

And any intrusion of * or & in its application will either not compile or produce a correct value conversion:

It will not compile a pointer cast.

C++
int* p = (to_signed*) &a_size_t ;             //will not compile

nor a pointer cast with value semantics:

C++
int& x = (to_signed&) a_size_t ;              //will not compile

and it will compile and work correctly when subjected to the normally catastrophic single ampersand edit.

C++
int x = (to_signed&) a_size_t ;               // compiles and works correctly    

All of this is to ensure that nothing can compile that might deviate it from its purpose which is:

  • To make the exact conversion visible and make sure that is indeed what is happening.
  • To be able do this elegantly without making a big fuss in code that needs to be kept readable.

Using the Code

It is implemented by a single header number_cast.h which contains just one class definition for number_cast.

In its native form, number_cast is verbose and requires a full specification of the cast involved.

C++
number_cast<ToType, FromType, bRounding=false>

This is what enables it be so tightly scoped and do the right thing. Note that the destination type comes first – it just sits better that way in code.

C++
int x = number_cast<int, size_t>(a_size_t); 

However, like anything else, if you use it a lot, you wouldn't refer to its specification every time you use it. You would give it a name and use that instead. And so it is with number_cast conversions.

C++
using to_signed = number_cast<int, size_t>;

Named number_casts are correctly called using their name with the C style casting syntax in its functional and standard from.

C++
int x = to_signed (a_size_t);

//and also

int x = (to_signed) a_size_t;

This is useful because you can choose the one that sits most comfortably in your code. This flexibility enables you to

  • avoid the unsightly accumulation of nested parenthesis,
  • insert the cast as a prefix with one paste,
  • or delineate clearly where the argument begins and ends.

Don't static_cast them, remember they are already the much more tightly scoped number_cast.

C++
int x = static_cast<to_signed >(a_size_t); //NO! This is silly.

If you want formal verbosity, then use the unnamed verbose number_cast with its full specification.

C++
int x = number_cast<int, size_t>(a_size_t); 

The following is my choice for frequently used conversions that I want to be visible but also to sit comfortably in my code. The names express what I want them to do and they all contain the word 'to' as a clear hint that they are conversions,

C++
using to_signed = number_cast<int, size_t>;    //unsigned integers mess up my maths
using trunc_to_int = number_cast<int, double>; //this is what (int) does with a double
using round_to_int = number_cast<int, double, true>;    //rounding is often preferable
using to_real = number_cast<double, int>;               //I need this integer to be 
                                                        //seen as a real number

Here are some examples employing them:

C++
int inum = 5;
double dnum = to_real(inum) / 2; //result dnum = 2.5
C++
int i= -1;

while (++i < (to_signed) my_std_vector.size())
{

}
C++
double average = sum / n;

int xPixel = (round_to_int)  average;

In this case, using names that describe the action of the conversion has three advantages:

  • They are easy to visually digest.
  • The action simultaneously implies where it is coming from and where it is going
  • They are less likely to clash with other names that aren't conversion actions.

It is based on an implicit assumption that by default, I work with int, size_t and double and that may not suit everyone. I chose just four because it's all I need and it doesn't create a steep learning curve for anyone reading my code.

Any other conversions will be unusual for me so I will be happy to let them stand as verbose unnamed number_casts.

You find these defined in named_number_casts.h.


number_cast and its named aliases will ensure that its argument and the variable that consumes the result match the input and output types of the cast. This extra level of type safety prevents code that would be misleading or contradictory from being compiled.

C++
int res = to_signed(a_double);      //error – wrong argument type
double res = to_signed(a_size_t);   //error – wrong destination type
int res = to_signed(a_size_t);      //ok -  argument & destination types match cast

However, this will also prevent compilation if the destination type is undefined or ambiguous:

C++
auto x = to_signed( a_size_t);      //error destination undefined

MyType dest = to_signed( a_size_t); //error cannot resolve multiple 
                                    //conversions of MyType 

MyFunction(to_signed( a_size_t));   //error cannot resolve multiple 
                                    //overloads of MyFunction

These can be resolved by applying the () operator to the cast which will relax the requirement to check the destination type.

C++
auto x = to_signed( a_size_t)();       //ok

MyType dest = to_signed( a_size_t)();  //ok 

MyFunction(to_signed( a_size_t)());    // ok

...BUT before applying the () operator, you should look carefully at why it has become necessary. There is a high chance that it may be because you have misunderstood something. In the first of these examples, it would have made more sense to have written simply:

C++
int x = to_signed( a_size_t); 

With the others, there is a good chance that the correct solution might be to remove the cast altogether and let those multiple conversions or overloads do their job.

It looks wrong to see operator () applied to a cast and this should stand as a reminder that a level of type safety has been removed because of some kind of conflict or awkwardness.


By default, conversion overflows will be checked and caught in Debug builds throwing a std::overflow_error if one occurs. You may want to activate them in release builds for some very sensitive or risk running applications or to catch errors in the field. This will add the overhead of the test but no more. To do this, edit number_cast.h and change:

C++
#ifdef _DEBUG
#define NUMBER_CAST_DO_CONVERSION_CHECKS
#endif

to:

C++
//#ifdef _DEBUG
#define NUMBER_CAST_DO_CONVERSION_CHECKS
//#endif

Illustrate Your Code

Named number_casts can illustrate your code and help you to see what it is doing. They also reliably mark the boundaries between your use of different numeric types.

This is how I am informed by the ones I use:

  • (to_signed) arg - Tells me that arg is unsigned and I am preparing it to participate in signed arithmetic or comparisons. It also tells me I am not expecting arg to have a value anywhere near 2 billion. Mostly, it will be dealing with collections returning their size as size_t.
  • (trunc_to_int) arg - Tells me that arg is a real number and I am either forming a count of how many times something will fit or I don't want to pay for the overhead of rounding – maybe the numbers are large enough that truncation doesn't matter.
  • (round_to_int) arg - Tells me that arg is a real number and I do really want the best integer approximation. I will see these clustered around my code that sets pixels on the screen that represent the results of calculations carried out with real numbers .
  • (to_real) arg - Tells me that arg is an integer and I want to do real number arithmetic with it. I am likely to find this deployed multiple times within complex calculations. That is why it has the shortest name.

These are useful cues that are easily digested when visually scanning code and the tight scoping of number_cast that sits behind them ensures that they faithfully respresent what is happening.

Design

It is a Function Object

The design employs the same principles as in the previous article but here we will go straight for a fully generic expression of it . The following is the bare bones of its operation (we will deal with overflow checking later):

C++
template <class ToType, class FromType> 
class number_cast
{
    const FromType v;
public:
    explicit inline number_cast(FromType _v) : v(_v)
    {}

    inline operator const ToType() const 
    {
        //the static_cast is simply to emphasize where the conversion takes place.
        return static_cast<ToType>(v);
    }
};

Its constructor takes the argument and its conversion operator delivers the result.

It is an implicitly called function object and that will prove to be advantageous in many aspects of its design and use. Two come free.

  • Specific conversions can easily be aliased to a descriptive name because it is a type:

    C++
    using to_signed = number_cast<int, size_t>;
  • It can be called as a function:

    C++
    int x = number_cast<int, size_t>(a_size_t);
    or as a casting prefix because it is a constructor call:
    C++
    int x = (number_cast<int, size_t>) a_size_t;

The two combined produce the uncluttered appearance and flexible syntax of a traditional C style cast:

C++
int x = to_signed (a_size_t);

and:

C++
int x = (to_signed) a_size_t;

Tightening Its Grip

As it stands, its function of defining and declaring the conversion taking place is easily undermined by implicit conversions as it reads the argument and assigns the result. So the following will compile:

C++
size_t x = (to_signed) a_double;

resulting in three conversions, two of which are invisible:

  • An implicit conversion from double to size_t (the input type of to_signed)
  • The explicit to_signed conversion from size_t to int
  • An implicit conversion from int (the output type of to_signed) to size_t

This allows huge deviations between what is being stated and what is being done just as can happen with standard casts and static_cast.

We can stop the implicit input conversion by providing multiple viable constructors (one more is enough) – it deprives the compiler of a clear target for implicit conversions, so it doesn't try them. So we put in one more constructor to a built in numeric type but make it private because we still only want the constructor taking size_t to work. It will be considered in overload resolution even though ultimately it can't be reached. This may seem strange but it is defined behaviour, you can rely on it.

This won't work with the output conversion because the variable being assigned to is the target of implicit conversions. But we can do it another way. We provide another templated conversion operator delivering any type. A destination that is not the correct output type will specialise the templated conversion to its type rather than invoke an implicit conversion. The specialized templated conversion will then fail to compile.

Here is an improved type safe version:

C++
template <class ToType, class FromType> 
class number_cast
{
    const FromType v;

    //prevents implicit conversion of input
    explicit inline number_cast(ToType _v) : v(_v)
    {}

public:
    //reads argument
    explicit inline number_cast(FromType _v) : v(_v)
    {}

    //delivers output to correct type
    inline operator const ToType() const 
    {
        //the static_cast is simply to emphasize where the conversion takes place.
        return static_cast<ToType>(v);
    }

    //prevents implicit conversion of output
    template<class T>
    inline operator const T() const {
        const int f = 0;
        f = 6; //prevents compilation
        return v;
    }
};

In the final version in the source code, you will also find a template type test for number_cast to prevent it from specializing with invalid parameters:

C++
class = std::enable_if_t<
        std::is_arithmetic_v<ToType> 
        && std::is_arithmetic_v<FromType>
        && (!bRound || (std::is_floating_point_v<FromType> 
                                && std::is_integral_v<ToType>)
            )
        >

and the copy constructor is deleted because correct use never requires it to be copied.

C++
inline number_cast(number_cast const& _v) = delete;

Rounding and Conversion Overflow Checks

That leaves just the Implementation of rounding and overflow checks. Both use the same system of compile time analysis and selection. The rounding is simpler and is always compiled so we can look at it first.

The conversion can take three forms: rounding to an signed int, rounding to an unsigned int and no rounding. So these are enumerated:

C++
enum roundType {
        none,
        not_signed,
        is_signed
    };

and three specializations of struct Conversions are provided, one for each of these cases. Each implements a convert method:

C++
template<int RoundType = roundType::none >
struct Conversions
{
    static inline ToType convert(FromType _v) {
        return static_cast<const ToType>(_v);
    }
};
template<>
struct Conversions<roundType::not_signed>
{
    static inline ToType convert(FromType _v) {
        return static_cast<const size_t>(_v + 0.5);
    }
};
template<>
struct Conversions<roundType::is_signed>
{
    static inline ToType convert(FromType _v) {
        return static_cast<const int>((_v < 0) ? _v - 0.5 : _v + 0.5);
    }
};

A constant is evaluated to determine which specialization is compiled:

C++
static constexpr int rounding_type = 
        
        (bRound && std::is_floating_point_v<FromType>) ?

            (std::is_unsigned_v<ToType>) ?

                roundType::not_signed
                : (std::is_integral_v<ToType>) ?

                    roundType::is_signed
                    : roundType::none
                : roundType::none;

and the conversion is called as follows:

C++
return Conversions<rounding_type>::convert(v); 

The handling of conversion overflow checks follows the same pattern but is a little more complex. The tests that may need to be applied are greater in number and the logic of selecting which to a apply is not easy to follow when expressed as a single compile time evaluation:

C++
//Determines which overflow checks should be applied
    static constexpr int check_type = 
    
    //This is the core logic for determining which overflow checks
    //need to be compiled. It is applied as a single compile time evaluation.

    (dest::is_real)? 
         
        checkType::no_test
        : (dest::notsigned && src::is_signed_int)? 

            (dest_smaller)? 

                checkType::src_non_negative_and_less_than_dest_max
                : checkType::src_non_negative

            : (dest::is_anyint && src::notsigned && dest_smaller)? 

                checkType::src_less_than_dest_max
                : (src::is_real || (dest::is_signed_int && dest_smaller))? 

                    (dest::notsigned)? 

                        checkType::src_non_negative_and_less_than_dest_max
                        : (dest::is_signed_int)? 

                            checkType::src_between_dest_min_and_dest_max
                            : 0
                    : 0;

It is preceded by half a page of symbol definitions to keep it as tidy as it is. It is what enabled the diversity of conversion overflow checks to be embraced by a generic design.

A late addition was adding operator ()() as an explicit command to return the converted value without checking that the destination is of the right numerical type. This is sometimes needed when the destination type is ambiguous.

C++
//yields result without checking where it is going.Only
    //to be directly called when destination is ambiguous
    inline constexpr ToType operator()() const
    {
#ifdef NUMBER_CAST_DO_CONVERSION_CHECKS
        Checks<ToType, FromType, check_type, bRound>::check(v);
#endif
        return Conversions<rounding_type>::convert(v);
    }

There is a bit of complexity to the finished product, but the operative parts remain just one constructor and one conversion operator.

Summary

  • number_cast provides a solid type safe basis for numerical type conversions and named (smart) number_casts give it an uncluttered and syntactically convenient expression that allows it to sit comfortably in complex code.

  • It is a reliable and convenient solution to the prevailing discomfort of using just (int) or cratering your code with static_casts with only a vague idea of why it is any better.

  • Rounding is added as an integrated option because it is very often what you really want.

  • Conversion overflow checking has been added as an extra level of run-time safety for debug builds so that overflows don't go undetected during development.

  • It is non-standard but it is just a class with a constructor and a conversion operator. It is fairly simple to verify to yourself and to others that it will work correctly and is reliable to use.

  • Named (smart) number_casts will be a standard that you set. Choosing those names is an important design decision that will have a big impact on what your code tells you and anyone else reading it.

History

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Retired
Spain Spain
Software Author with engineering, science and mathematical background.

Many years using C++ to develop responsive visualisations of fine grained dynamic information largely in the fields of public transport and supply logistics. Currently interested in what can be done to make the use of C++ cleaner, safer, and more comfortable.

Comments and Discussions

 
GeneralMy vote of 5 Pin
Gary Wheeler9-May-22 4:17
Gary Wheeler9-May-22 4:17 
GeneralRe: My vote of 5 Pin
john morrison leon9-May-22 5:43
john morrison leon9-May-22 5:43 
Thank you.

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.