C++ / FUNDAMENTAL TYPES AND VARIABLES
Characters, booleans, and char signedness surprises
Tell char, signed char and unsigned char apart, handle bytes above 127 without undefined behaviour, and predict how bool converts to and from integers.
What you will learn
- Choose unsigned char for raw bytes, signed char for small numbers, char for text
- Cast to unsigned char before every std::toupper or std::isdigit call
- Print a byte's numeric value with +c or static_cast<int>(c), not with cout << c
- Predict bool conversions: any non-zero becomes true, true becomes exactly 1
Understanding Characters, booleans, and char signedness surprises
A char is an integer type that also happens to be the unit the language measures memory in: sizeof(char) is 1 by definition and every other size is expressed in multiples of it. What makes it unusual is that the standard defines three separate one-byte types, char, signed char and unsigned char, and leaves the range of plain char up to the implementation. On x86 Linux, Windows and macOS plain char is signed; on most ARM and PowerPC Linux ABIs it is unsigned, because each ABI picked whichever sign extension its byte-load instruction did for free. wchar_t, char16_t, char32_t and C++20's char8_t are further distinct types, not aliases for any of these.
That freedom only matters for bytes whose top bit is set. If char is signed, the byte 0xE9 read out of a file holds the value -23, so c > 127 is a comparison the compiler can prove false, using c as an array index reaches in front of the array, and widening it to int sign-extends to -23 rather than producing 233. The fix is not a wider type but a correctly signed one: cast to unsigned char at the point where the byte becomes a number, because that conversion reinterprets the same eight bits as 0 to 255 on every platform. Reserve plain char for text and write unsigned char (or std::uint8_t) whenever you mean a byte.
bool looks like a one-bit integer, but it converts differently in each direction. Converting any scalar to bool asks only whether the value is non-zero, so 256, -1 and 0.5 all become true, which is why bool never truncates the way char does. Going the other way a bool promotes to int 0 or 1 exactly, so true + true is 2 and std::cout << flag prints 1 until you insert std::boolalpha. Because the promotion happens before the operator runs, t | t has type int while t || t stays bool, and ++flag, once an idiom for forcing true, was removed in C++17.
<climits>
<iostream>
int main() {
signed char s = static_cast<signed char>(0xE9); // 233 does not fit
unsigned char u = 0xE9; // 233 fits
std::cout << "0xE9 as signed char: " << static_cast<int>(s) << '\n';
std::cout << "0xE9 as unsigned char: " << static_cast<int>(u) << '\n';
std::cout << "same eight bits: " << (static_cast<unsigned char>(s) == u) << '\n';
char letter = 'A';
std::cout << "letter prints as " << letter
<< " but its value is " << +letter << '\n';
bool flag = s; // -23 is non-zero
std::cout << std::boolalpha
<< "bool from -23: " << flag
<< ", and back to int: " << static_cast<int>(flag) << '\n';
std::cout << "sizeof(char) = " << sizeof(char)
<< ", CHAR_BIT = " << CHAR_BIT << '\n';
}
char, signed char and unsigned char are three distinct one-byte types, and since plain char's signedness is chosen by the implementation, a byte only has a portable numeric meaning as unsigned char.
Worked examples
Three types, not two spellings
Overload resolution proves that plain char is a type of its own, distinct from both signed char and unsigned char.
<iostream>
void show(char) { std::cout << "char\n"; }
void show(signed char) { std::cout << "signed char\n"; }
void show(unsigned char) { std::cout << "unsigned char\n"; }
int main() {
char c = 'A';
signed char s = 'A';
unsigned char u = 'A';
show(c);
show(s);
show(u);
show('A');
std::cout << sizeof('A') << '\n';
}
Example explained
Line 1show(c) selects the char overload even on a platform where char has exactly the same range and representation as signed char.
Line 2All three overloads coexist without ambiguity because they take three different types; there is no 'default' char that one of the others aliases.
Line 3show('A') resolves to char because a character literal has type char in C++, which is also why sizeof('A') is 1 here and 4 in C.
Line 4The same distinctness means std::vector<char> and std::vector<unsigned char> are unrelated types that will not convert to each other.
Why <cctype> needs the cast
Shows the required unsigned char cast when feeding bytes to the classification functions, using text that contains non-ASCII bytes.
<cctype>
<iostream>
char up(char c) {
// std::toupper wants a value of unsigned char, or EOF; nothing else.
return static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
}
int main() {
const char* s = "caf\xC3\xA9 42"; // "cafe' 42" encoded in UTF-8
std::cout << "upper: ";
for (const char* p = s; *p != '\0'; ++p)
std::cout << up(*p);
std::cout << '\n';
std::cout << std::hex << "bytes:";
for (const char* p = s; *p != '\0'; ++p)
std::cout << ' ' << static_cast<int>(static_cast<unsigned char>(*p));
std::cout << '\n';
}
Example explained
Line 1On a signed-char platform *p for the byte 0xC3 is -61, and passing a negative value to std::toupper is undefined behaviour, not merely a wrong answer.
Line 2static_cast<unsigned char> reinterprets those eight bits as 195 before the integral promotion to int, so the argument is always in the required range.
Line 3The hex loop uses the same cast, which is why it prints c3 and a9 instead of negative numbers regardless of how the compiler signs char.
Line 4std::toupper maps one byte at a time in the C locale, so the two bytes of é pass through untouched: byte-wise case mapping cannot handle multibyte text.
bool converts, char truncates
Contrasts the boolean conversion with a narrowing conversion to char, and shows where a bool has already turned into an int.
<iostream>
int main() {
int big = 256;
std::cout << std::boolalpha;
std::cout << "bool(256) = " << static_cast<bool>(big) << '\n';
std::cout << "char(256) = " << static_cast<int>(static_cast<char>(big)) << '\n';
bool t = true;
std::cout << "t + t = " << t + t << '\n';
std::cout << "t | t = " << (t | t) << '\n';
std::cout << "t || t = " << (t || t) << '\n';
}
Example explained
Line 1Converting to bool only asks whether the value is non-zero, so 256 becomes true, while converting to char keeps the low byte and gives 0.
Line 2t + t is int arithmetic: each operand is promoted to 0 or 1 first, and the result 2 has type int, so it cannot be a bool.
Line 3std::boolalpha changes the formatting of objects of type bool only, and t | t has type int after promotion, so it still prints 1.
Line 4t || t is one of the few operators that yields bool, which is why the same stream prints true for it.
Important notes
sizeof(char) is 1 by definition, but CHAR_BIT is only guaranteed to be at least 8 and sizeof(bool) is implementation-defined, so never bake either into a file or wire format.
Reading an uninitialised bool is undefined behaviour: a byte holding 2 is not a valid bool, and after optimisation both if (b) and if (!b) can appear to be true.
Common mistakes
Detecting non-ASCII with if (c > 127) on a plain char: where char is signed the value promotes to a negative int, so the test is never true and UTF-8 bytes are silently processed as ASCII.
Calling std::isalpha(c) or std::toupper(c) with a plain char: for bytes above 0x7F the argument is negative, which is undefined behaviour and in practice reads in front of the implementation's classification table, giving nonsense or a crash.
Storing std::cin.get() or getchar() in a char and comparing with EOF: on signed-char builds a legitimate 0xFF byte compares equal to -1 and truncates the input, and on unsigned-char builds the comparison never matches at all, which is why those functions return int.
Try it yourself
Change, predict, then run
Put the bytes 0x41, 0x7F, 0x80 and 0xFF into a char array and print each one twice, as static_cast<int>(c) and as static_cast<int>(static_cast<unsigned char>(c)). Then print std::numeric_limits<char>::is_signed and work out which column that flag explains and which one is the same everywhere.
Open the C++ workspaceCheck your understanding
A byte read from a file has the bit pattern 0xFF and is stored in a plain char c. Which test tells you the byte was 255 on every platform, whether or not char is signed there?
- c == 0xFF
- static_cast<unsigned>(c) == 255u
- static_cast<unsigned char>(c) == 0xFF
- static_cast<int>(c) == 255
Show answer
Converting to unsigned char reinterprets the same eight bits as a value in 0 to 255 whichever way the platform signs char, and the promotion to int for the comparison preserves 255. Option 1 looks like the same fix but converts to a 32-bit unsigned type: on a signed-char platform c is -1, so it becomes 4294967295 and the test fails. Options 0 and 3 compare a sign-extended -1 against 255 and are simply false there.