In our journey so far, we have discovered how functions and variables work with specific data types. Each type, like int
or float
, plays a unique role. But what happens when we mix these types - for example, what happens when we use an int
where a float
is expected?
This scenario introduces us to the concept of implicit conversion.
It allows us to use different types of values interchangeably in some situations, without needing to manually convert them. For instance, using an int
value where a float
is needed. The compiler, our code's translator, handles this conversion for us.
However, there's a twist. Not all conversions are equal. Some are straightforward, like turning an int
into a float
. Others, like converting a string
to an int
, are not possible implicitly.
In this lesson, we’ll explore how implicit conversions work with variables, functions, and operators. We'll also tackle the concept of narrowing casts, a special type of conversion that needs extra attention.
Data types in C++ can allow values of their type to be created from a value of a different type. We'll see how to give our custom data types this ability in a later chapter.
For now, we'll focus on the built-in types. We've already seen these implicit conversions in action when working with numbers. For example, we've been able to create a float
from an int
:
float MyNumber { 5 }; // 5.0
This works, because the float
data type includes the ability to create one of its objects (eg, 5.0
) using an object of the int
type (eg, 5
)
This is not always possible. For example, the float
data type cannot create objects from a string
. Let's try:
string MyString { "Hello" };
float MyNumber { MyString };
This fails with an error message similar to:
No viable conversion from std::string to float
Just like with variables, C++ can automatically convert values when they're returned from functions. While it's best practice to return the exact type a function declares, C++ will perform implicit conversions when necessary.
Here are some examples of implicit conversions with function returns:
// Converting 0 to a boolean results in false
bool IsDeadFrom0() {
return 0; // false
}
// Converting a non-0 integer to a
// boolean results in true
bool IsDeadFrom42() {
return 42; // true
}
// Converting false to an integer results in 0
int LevelFromFalse() {
return false; // 0
}
// Converting true to an integer results in 1
int LevelFromTrue() {
return true; // 1
}
// Conversions can happen multiple times
// This function will convert 50 to true...
bool GetHealth() {
return 50;
}
// This variable initialization
// converts true to 1
int Health { GetHealth() }; // Health will be 1
While C++ allows these conversions, it's better to return the exact type your function declares. This makes your code clearer and helps prevent unexpected behavior.
Behind the scenes, operators are very similar to functions. We’ll see examples of this later in the course, where we implement our custom operators.
Because of this, operators also implement implicit conversions in the same way we covered above:
// This will result in 6
int Level { 5 + true };
Implicit casts are quite contentious. Many argue that programming languages shouldn't do them at all, because they're a source of errors and confusion.
We may, for example, write code like the following example. Note, we’re using the =
syntax to initialize this variable, rather than the { }
syntax we typically prefer:
#include <iostream>
using namespace std;
int main() {
int Pi = 3.14;
}
Our intent here is almost certainly to initialize Pi
to 3.14
but because we accidentally set the type to int
rather than float
, we'll be using 3
.
The compiler will allow this, and we may not even notice that our calculations are less accurate than we intended. This is an example of a narrowing cast.
Narrowing casts are operations where converting one data type to another could cause loss of data. For example, storing 3.14
as an int
would cause the .14
component to be lost, leaving us with only 3
.
Even though the C++ specification does allow narrowing casts at variable initialization using the =
syntax, compilers will often detect this issue and output a warning.
Unlike a compiler error, a compiler warning will not prevent our code from compiling. Instead, it lets the compiler draw our attention to scenarios where our code might be technically valid, but seems unusual or potentially dangerous.
IDEs will typically present compiler warnings somewhere on their UI after our program compiles. In Visual Studio, we can see warnings by expanding the View menu from the top menu bar, and selecting Error List
One advantage of Uniform Initialisation using {
and }
is that it protects us against this.
If we update our previous example to use uniform initialization, the compiler will fail and alert us to the issue. This is better than letting a potential bug slip through:
int Pi { 3.14 };
Our compiler will tell us where the problem is, with an error:
Error: conversion from 'double' to 'int' requires a narrowing conversion
Here are some slightly more complex examples:
float GetPiFloat() { return 3.14; }
double GetPiDouble() { return 3.14; }
// Converting a float to an int will fail
int A{GetPiFloat()};
// Converting a double to an int will fail
int B{GetPiDouble()};
// Converting a double to a float will fail
float C{GetPiDouble()};
// But converting a float to a double is fine
// Any float can be converted to a double
// without data loss
double D{GetPiFloat()};
In an earlier lesson, when talking about literals, we pointed out that an expression like 4.0
is a double
, rather than a float
.
We've also seen how a statement like the following is valid:
float MyNumber { 4.0 };
This may seem to conflict with what we just read. Isn't that code narrowing a double to a float?
The reason the above code works is that the compiler can determine at build time if the double 4.0
can be stored as the float 4.0f
without data loss. It can, so the compiler allows it.
4.0
is an example of a constant expression, that is, something that can be evaluated at compile time. We have a dedicated lesson on this coming later in this course.
For now, just note that if 4.0
was the value contained in a variable or the return of a function, it would no longer be a constant expression.
The following code shows an example of that.
double MyDouble { 4.0 };
float MyFloat { MyDouble };
After this small change, we get the same narrowing cast error we described in this section:
error: non-constant-expression cannot be narrowed from type `double` to `float`
It is, of course, possible to do conversions that result in data loss, like converting an int
to a bool
. Uniform initialization just prevents it from being done automatically, because there's a risk we won't notice it's happening.
The compiler would rather we be explicit in our intentions. We'll be introduced to explicit casting soon.
In this lesson, we delved into the concepts of implicit conversions and narrowing casts. Here's what we covered:
int
to a float
.double
to an int
.We hope this lesson has shed light on how implicit conversions and narrowing casts work in C++, enhancing your understanding of the language's versatility and potential pitfalls.
As we wrap up our exploration of implicit conversions and narrowing casts, we set our sights on the next topic: Function Arguments and Parameters. Here's a sneak peek of what we'll dive into:
Going into more depth on what is happening when a variable is used as a different type
Become a software engineer with C++. Starting from the basics, we guide you step by step along the way