Skip to content

← Values, Types, Initialisation step 3 of 4

Easy Primitives

Braces refuse to lose your data

C++ has more ways to initialise a variable than any language needs. Two of them differ in a way that matters:

int a(3.9);   // 3   — silently truncated
int b{3.9};   // error: narrowing conversion

Parentheses perform the conversion and say nothing. Braces refuse to compile. Same value, same target type, opposite outcome — and the second one is the behaviour you want almost everywhere.

What “narrowing” means

A conversion is narrowing when the destination type cannot represent every value of the source type. Braces reject narrowing conversions unless the value is a constant the compiler can prove fits:

int    a{3.9};              // error   — double to int, loses 0.9
int    b{3.0};              // error   — still double to int
int    c{3};                // fine
char   d{300};              // error   — constant, and it does not fit
char   e{65};               // fine    — constant, and it does
double f{3};                // fine    — int to double is not narrowing

Note the fourth line. Braces catch a constant that does not fit, which is a compile-time error for a bug that would otherwise be a runtime surprise.

Why this is a habit and not a rule

You will still see and write parentheses. std::vector<int> v(10) makes a vector of ten zeros, and std::vector<int> v{10} makes a vector containing the single element ten — that is the famous case where braces are the surprising one, because std::initializer_list wins when it can.

So the practical guidance is narrower than “always use braces”:

Use braces when you are initialising a value from another value. Use parentheses when you are calling a constructor whose arguments are configuration rather than contents — sizes, capacities, counts.

The other reason braces are worth the habit

int x{};       // zero
double y{};    // 0.0
int* p{};      // nullptr
std::string s{};  // empty

An empty brace initialiser value-initialises: zero for arithmetic types, null for pointers, the default constructor otherwise. int x; at block scope does none of that — it leaves x holding whatever was there, and reading it is undefined behaviour. That was mistake three in the previous problem.

Your task

struct Pixel {
    std::uint8_t r;
    std::uint8_t g;
    std::uint8_t b;
};

Pixel scale(const Pixel& p, double factor);

Multiply each channel by factor and clamp the result to 0..255 before storing it. std::uint8_t holds 0 to 255; a scaled channel can exceed that, and letting it wrap turns a bright red pixel into a dark one.

The starter multiplies and assigns without clamping, using parentheses so that nothing complains. Convert the initialisation to braces and the compiler will tell you where you have not thought it through.

One more thing you will meet on the way. If you build the result as Pixel{a, b, c}, clang-tidy will ask for Pixel{.r = a, .g = b, .b = c}designated initialisers, C++20. Six characters, and they remove the class of bug where two fields of the same type get swapped. For r, g and b that is a bug you would see on screen and a test might not.