-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Object-Oriented C++
-
Section 4: Memory Management
-
Section 5: Templates and Generic Programming
-
Section 6: The Standard Template Library
-
Section 7: Modern C++ Features
-
Section 8: Error Handling
-
Section 9: Multithreading
-
Section 10: Operator Overloading and Type Conversion
-
Section 11: Advanced Topics
-
Section 12: Tooling and Build Systems
-
Section 13: Design Patterns in C++
-
Section 14: Interfacing with C and Systems Programming
-
Section 15: Networking and IPC Basics
-
Section 16: Graphics and Game Programming Basics
-
Section 17: The Boost Libraries
-
Section 18: Data Structures and Algorithms in C++
-
Section 19: Practical Projects
-
Section 20: More Concurrency Patterns
-
Section 21: More OOP and Design Practice
-
Section 22: File I/O and Streams
-
Section 23: More Standard Library
-
Section 24: Practice Exercises
-
Section 25: Interview and Algorithm Practice
-
Section 26: Compiler and Language Internals
-
Section 27: GUI and Application Frameworks Overview
-
Section 28: Testing and Quality Practices
-
Section 29: Numerics Library
-
Section 30: Concepts Library (C++20)
-
Section 31: Ranges Library (C++20) In Depth
-
Section 32: More Utility Library
239: Standard Library Concepts: same_as, convertible_to, integral
You've likely spent a fair amount of time writing templates. You know the drill: you make a function generic, and then you spend an hour staring at a 50-line compiler error because you passed a std::string into a function that tried to perform a bitwise shift. The problem is that without constraints, a template is essentially a promise that "this code will work for any type," which is almost never actually true.
#include <iostream>
#include <string>
// A simple utility to flip bits
template <typename T>
T flip_bits(T value) {
return ~value;
}
int main() {
std::cout << flip_bits(42); // Works fine
std::cout << flip_bits(std::string("Hello")); // BOOM: Compiler meltdown
}
The headache of overly permissive templates
The code above is a classic trap. The flip_bits function logically only makes sense for integers. However, because I didn't specify any requirements for T, the compiler tries to instantiate flip_bits for std::string. It only realizes that the ~ operator doesn't exist for strings inside the function body. This results in a massive error trace that points to the internal implementation rather than the call site in main().
In the old days, we'd use std::enable_if, but that syntax is, frankly, a nightmare to read. C++20 gave us Concepts, which allow us to move the requirements from the function body to the function signature. This makes the error messages readable and the intent clear.
Constraining types with std::integral
If we want to ensure that flip_bits only ever touches integer types, we can use the std::integral concept. It's a predefined requirement that checks if a type is an integral type (like int, char, long, etc.).
#include <concepts>
#include <iostream>
#include <string>
template <std::integral T>
T flip_bits(T value) {
return ~value;
}
int main() {
flip_bits(42); // Still works
// flip_bits(std::string("Hello")); // Now this is a clean "constraint not satisfied" error
// flip_bits(3.14); // Also fails cleanly because double is not integral
}
Now, the compiler stops you at the door. If you try to pass a double or a std::string, the error tells you exactly why: T does not satisfy std::integral. It's much more intuitive.
Enforcing exact types with std::same_as
Sometimes, std::integral is too broad. What if you're writing a low-level driver function that must take a uint32_t because that's exactly what the hardware register expects? You can't risk an implicit conversion from a short or a long.
That's where std::same_as comes in. It's the strictest check available; it returns true only if the types are identical.
#include <concepts>
#include <cstdint>
template <typename T>
requires std::same_as<T, uint32_t>
void write_to_register(T value) {
// Hardware-specific logic here
}
int main() {
write_to_register(10u); // Works (unsigned int usually matches uint32_t)
// write_to_register(10); // Fails! signed int is not same_as uint32_t
}
I use std::same_as sparingly. Overusing it can make your API feel brittle, but for memory-mapped I/O or strict protocol buffers, it's a lifesaver.
Allowing flexibility with std::convertible_to
On the opposite end of the spectrum, you often want a function to be flexible, as long as the input can be safely treated as a specific type. For example, imagine a logger that takes a std::string_view. You want it to accept std::string, const char*, and std::string_view.
Using std::convertible_to<From, To> allows you to express this "good enough" relationship.
#include <concepts>
#include <iostream>
#include <string_view>
#include <string>
template <typename T>
requires std::convertible_to<T, std::string_view>
void log_message(T message) {
std::cout << "[LOG]: " << std::string_view(message) << "\n";
}
int main() {
log_message("Hello World"); // Works (const char* is convertible)
log_message(std::string("C++20")); // Works (std::string is convertible)
// log_message(42); // Fails! int is not convertible to string_view
}
Notice how I used requires std::convertible_to<T, std::string_view> after the template declaration. This is just another way to write constraints—you can put them in the template parameter list or in a requires clause. I personally prefer the requires clause for complex logic because it keeps the template header cleaner.
📋 Practical Task
Exercise: Building a Type-Safe Math Operation Guard
You are tasked with creating a calculate_offset function. This function should be highly constrained to prevent common bugs in a coordinate system.
Requirements:
- The function must take two arguments:
baseandoffset. - The
basemust be exactly of typedouble(usestd::same_as). - The
offsetmust be any integral type (usestd::integral). - The function should return the sum of the two as a
double.
Test your implementation with the following cases:
calculate_offset(10.5, 5)→ Should compile and return15.5.calculate_offset(10.5, 5L)→ Should compile (sincelongis integral).calculate_offset(10, 5)→ Should fail to compile (since10is anint, not adouble).calculate_offset(10.5, 2.5)→ Should fail to compile (since2.5is not an integral type).
There are no comments for now.