This project has been created as part of the 42 curriculum by baelgadi.

CPP Module 06

Casts and type conversion


Half the systems in my body read me as a man while the other reads me as hardware.
Same bits but 2 different types, and neither one is lying.

A cast is exactly that: telling the compiler which of my 2 selves it should be looking at.
Pick the wrong one and nothing crashes right away which is exactly the problem.


Previously…


CPP Module 06:

3 exercises. C++98 standard only.
Compiler: c++ · Flags: -Wall -Wextra -Werror · Must compile with -std=c++98

Exercise 00: Conversion of scalar types · ex00/ · ScalarConverter, literal detection, static_cast
Exercise 01: Serialization · ex01/ · Serializer, uintptr_t, reinterpret_cast
Exercise 02: Identify real type · ex02/ · Base/A/B/C, dynamic_cast, pointer vs reference

Forbidden: using namespace (-42) · friend (-42) · printf · *alloc · free · STL containers · STL algorithms · function bodies in headers · std::stringstream (ex00) · <typeinfo> (ex02)
Required: Orthodox Canonical Form on every class · ScalarConverter and Serializer must be impossible to instantiate · every conversion goes through an explicit C++ cast

The 4 Casts

In C we had 1 tool: (int)x and it did whatever it took to shut the compiler up. It never told you which one of these four jobs it was actually doing deep down.

(char)d // could be a value conversion...
(Data*)raw // ...or a raw bit reinterpretation...
(char*)str // ...or throwing away a const...
(Dog*)animal // ...or a downcast

C++ splits that into 4 keywords, and the whole point is that the name of the cast is the documentation.

Cast What it does Checked at Cost at runtime In this module
static_cast Related types e.g doubleint or intchar Compile time None ex00
reinterpret_cast Same bits, read them as something else Never really None ex01
dynamic_cast Downcast in a polymorphic hierarchy Run time RTTI lookup ex02
const_cast Adds or removes const or volatile Compile time None

3 of those 4 are free and unverified. Only dynamic_cast actually goes and asks the object what it is.

[!NOTE] A C style cast tries const_cast, then static_cast, then reinterpret_cast, in order, and takes the first one that compiles.
’Means typo can silently downgrade a value conversion into a bit reinterpretation. The named casts can’t do that to you.

[!WARNING] static_cast will happily narrow. static_cast<int>(1e20) is undefined behaviour.
Which is exactly why ex00 range checks before it casts. Never after.


ex00: Conversion of Literals

Before I route power anywhere, I run a diagnostic on what I’m actually holding.
ScalarConverter decides what the string is before it decides what to do with it.

enum LiteralType { PSEUDO_LIT, CHAR_LIT, INT_LIT, FLOAT_LIT, DOUBLE_LIT, INVALID_LIT };

static LiteralType  detectType(const std::string & s)
{
    if (isPseudoLiteral(s))
        return PSEUDO_LIT;
    if (isCharLiteral(s))
        return  CHAR_LIT;
    if (isIntLiteral(s))
        return INT_LIT;
    if (isFloatLiteral(s))
        return FLOAT_LIT;
    if (isDoubleLiteral(s))
        return DOUBLE_LIT;
    return INVALID_LIT;
}

The order matters, ’coz nan has to be caught before anything tries to read it as a number, and 'c' before anything tries to read those quotes as digits.

Then everything, and I mean everything, is funnelled into a double:

    double      d = 0;
    //...
    printChar(d);
    printInt(d);
    printFloat(d, pseudo);
    printDouble(d, pseudo);

That’s the entire architecture. A double is the widest pivot available in C++98 that could hold a char, an int and a float without losing anything that matters here.

Input type How it reaches the double Note
'c' static_cast<double>(literal[1]) The char between the quotes
42 std::strtod std::stoi is C++11
4.2f strtodstatic_cast<float> → back to double The float round trip is deliberate
4.2 std::strtod Straight through

That float round trip is doing real work:

        case FLOAT_LIT:
            d = static_cast<double>(static_cast<float>(std::strtod(literal.c_str(), NULL)));

[!NOTE] std::strtof would have been 1 call instead of 3 casts, but strtof is C99/C++11. strtod is C++98.
Narrowing the full precision double down to float rounds to infinity at exactly the same threshold strtof would have, and widening it back keeps that infinity.

[!IMPORTANT] A literal has to have a digit in it, not just a dot.
.f and +.f are not C++ literals, so isFloatLiteral tracks hasDot AND hasDigit.
Had we drop the digit check, .f would have silently converted to 0.0f, which is a wrong answer.


Pseudo-literals and the Edges of a double

nan, +inf, -inf and their f variants are not values you can parse. They’re states.
They get flagged on the way in and printed from the flag on the way out, because the double itself can no longer tell us which spelling it came from.

static bool isNaN(double d)
{
    return d != d;
}

static bool isPosInf(double d)
{
    return d > std::numeric_limits<double>::max();
}

d != d is only ever true for NaN, since it’s the one value that isn’t equal to itself. And anything strictly greater than the largest finite double has nowhere else to be but infinity.

Both of those are pure C++98. std::isnan and std::isinf are C99.

Once we’re holding NaN or infinity, 2 of the 4 outputs simply cannot exist:

nan +inf 2147483648 -1 128
char impossible impossible impossible impossible impossible
int impossible impossible impossible -1 128
float nanf +inff 2147483648.0f -1.0f 128.0f
double nan +inf 2147483648.0 -1.0 128.0

[!WARNING] std::isprint(c) with a plain char is undefined behaviour for negative values. The argument has to be representable as unsigned char, so we use static_cast<unsigned char> on every <cctype> call in the file.


A Class you ain’t allowed to build

There is no such thing as a ScalarConverter. Nobody owns one, nobody stores one.

class ScalarConverter
{
    public:
        static void convert(const std::string & literal);

    private:
        ScalarConverter();
        ScalarConverter(const ScalarConverter & src);
        ScalarConverter & operator=(const ScalarConverter & src);
        ~ScalarConverter();
};

All 4 are declared and never defined, and they’re private.
Declared, so the compiler doesn’t generate the default versions.
Private, so nothing outside the class can call them anyway.
ScalarConverter sc; doesn’t compile.

A namespace A class with private constructors
Can it be instantiated? Nothing to instantiate No, and the compiler says why
Can it have private helpers? Only via static at file scope Yes, private members
Reopenable elsewhere? Yes, anyone can add to it No, it’s closed
What the subject asks for This

[!NOTE] C++11 would write = delete on those four lines.
In C++98, “declare it private and never define it” is the idiom.


ex01: reinterpret_cast and uintptr_t

An address is a number. It has always been, and reinterpret_cast is simply the moment we stop pretending otherwise.

uintptr_t   Serializer::serialize(Data* ptr)
{
    return reinterpret_cast<uintptr_t>(ptr);
}

Data*   Serializer::deserialize(uintptr_t raw)
{
    return reinterpret_cast<Data*>(raw);
}

2 lines, zero instructions generated. Nothing moves, nothing is copied, no bit changes.
The only thing that changed is which type the compiler thinks it’s looking at.

uintptr_t is the specific point of the exercise: an unsigned integer type that’s guaranteed wide enough to hold a pointer and give it back unharmed.

# include <stdint.h> // <cstdint> is C++11

The round trip has to survive both halves of the test:

    uintptr_t   raw = Serializer::serialize(&original);
    Data        *recovered = Serializer::deserialize(raw);

    std::cout << (recovered == &original
            ? GREEN "  check        = OK (same address)\n" RESET
            : RED "  check        = KO (different address)\n" RESET)
        << std::endl;

[!WARNING] reinterpret_cast promises the round trip. It promises absolutely nothing about what lives at that address.
If we deserialize a number we made up, or the address of an object that’s already been destroyed, we would get a Data* that compiles perfectly and reads garbage.

[!NOTE] This is “serialization” in the narrow, in-process sense: the integer is only meaningful inside the running program that produced it. Write it to a file, restart, read it back, and it points at nothing.
Real serialization copies the contents, not the address.


ex02: dynamic_cast, the self diagnostic

Every other cast in this module trusts us. Not this one though.

Base *  generate(void)
{
    int random = std::rand() % 3;

    if (random == 0)
        return new A();
    if (random == 1)
        return new B();
    return new C();
}

The caller gets a Base* and genuinely does not know what’s underneath it.
The answer only exists at runtime so only a runtime tool can find it.

By pointer, failure is a value:
void    identify(Base* p)
{
    if (dynamic_cast<A*>(p))
        std::cout << "A" << std::endl;
    else if (dynamic_cast<B*>(p))
        std::cout << "B" << std::endl;
    else if (dynamic_cast<C*>(p))
        std::cout << "C" << std::endl;
    else
        std::cout << "Unknown" << std::endl;
}
By reference, failure is an exception

because there is no null reference to hand back:

    try
    {
        (void)dynamic_cast<A&>(p);
        std::cout << "A" << std::endl;
        return ;
    }
    catch(const std::exception& e) {}

The subject forbids <typeinfo> and std::bad_cast is declared in <typeinfo>.
Catching const std::exception & instead of const std::bad_cast & is necessary.

Also, very important:

class Base
{
    public:
        virtual ~Base();
};

Without that virtual function. Base has no vtable, no RTTI, and dynamic_cast doesn’t compile at all.
It’s also what makes delete obj; through a Base* legal in the first place (CPP04).

[!IMPORTANT] Base::~Base() {} lives in Base.cpp, not in the header. “No function implementation in a header” applies to an empty destructor too

[!NOTE] (void) on the reference cast is there because the result is never used, only the absence of a throw is. Without it -Wunused-value becomes -Werror


Instructions

# ──── ex00 ────
# Verify: the 3 subject examples print exactly what the PDF shows
# Verify: '.f', '+.f' and '-.f' are rejected, while '4.f', '.5' and '5.' still convert
cd ex00 && make
./convert 0 ; ./convert nan ; ./convert 42.0f
./convert .f ; ./convert 4.f ; ./convert .5
./convert 999999999999999999999999999999999999999.0f
./convert "'c'" ; ./convert 2147483648 ; ./convert 42abc
valgrind --leak-check=full ./convert 42.0f

# ──── ex01 ────
# Verify: the recovered pointer compares equal to the original one
cd ex01 && make
./serializer
./serializer | grep check
valgrind --leak-check=full ./serializer

# ──── ex02 ────
# Verify: the pointer line and the reference line always agree on the same letter
# Verify: 2 runs launched back to back do not produce the same sequence
cd ex02 && make
./identify
./identify | tr -d '\n' ; echo ; ./identify | tr -d '\n' ; echo
valgrind --leak-check=full ./identify

Resources


Anybody can put a cast in front of a value and make the error go away but that ain’t the skill.
The skill is being the one in the room who can say which of the 4 you reached for, what it checked, and what it didn’t.

Booyah.