Lecture 1: Course Intro, Funciton Overloading, Lvalues/Rvalues/Universal References and constexpr
Date: 2023-07-28
1. Functions Overloading
1.1 Rules for Function Overloading
- Function name should be the same.
- Change in number, type (data type), or sequence of parameters.
- Return type is not considered while differentiating the overloaded functions, so functions cannot be overloaded only based on return type.
1.2 Examples
- Different Number of Parameters:
void display(int a) {
cout << "Integer: " << a << endl;
}
void display(int a, double b) {
cout << "Integer: " << a << " and Double: " << b << endl;
}
int main() {
display(5); // calls the first function
display(5, 3.14); // calls the second function
}
- Different Type of Parameters:
void print(char c) {
cout << "Char: " << c << endl;
}
void print(double d) {
cout << "Double: " << d << endl;
}
int main() {
print('A'); // calls the first function
print(3.14); // calls the second function
}
- Different Sequence of Parameters:
void show(int a, char c) {
cout << "Integer: " << a << " and Char: " << c << endl;
}
void show(char c, int a) {
cout << "Char: " << c << " and Integer: " << a << endl;
}
int main() {
show(5, 'A'); // calls the first function
show('A', 5); // calls the second function
}
Remember, function overloading is determined at compile-time. It's a way to achieve compile-time polymorphism in C++.
Also, overloaded functions can have different return types, but they can't be differentiated based solely on their return type. That is, if two functions have the same name and the same parameter list but different return types, the compiler will consider this an error.
2. Argument-Dependent Lookup (ADL)
2.1 What is ADL?
ADL, or Argument-dependent lookup, is a mechanism in C++ where the compiler selects the appropriate function to call based on the arguments' types and their associated namespaces.
It means that when you call a function, C++ not only looks in the current scope and the global scope for the function name, but it also looks in the namespaces associated with the argument types.
2.2 Examples
Suppose you have a namespace with a function and a class:
namespace MyNamespace {
class MyClass {};
void fun(MyClass mc) {
std::cout << "Function in MyNamespace" << std::endl;
}
}
int main() {
MyNamespace::MyClass obj;
fun(obj); // This will correctly call MyNamespace::fun even without specifying the namespace.
}
In the above example, even though we didn't prefix fun(obj)
with MyNamespace::
, the compiler correctly determines which function to call using ADL. Since obj
is of type MyNamespace::MyClass
, the compiler also looks inside MyNamespace
to find a matching function.
ADL is especially important in C++ for operators, as it allows you to define custom operators for user-defined types in their respective namespaces and still use them without prefixing the namespace. But remember, while ADL can be very helpful, it can also lead to unexpected results if not used carefully. It's crucial to know where functions reside and to understand which function gets called and when.
3. Overloading Streaming Operators
Overloading the streaming operators (namely, <<
and >>
) in C++ is a common practice when you want to provide custom input/output functionalities for user-defined types, such as classes. It helps in making these types seamlessly integrate with standard C++ IO facilities like std::cout
and std::cin
.
3.1 Overloading the Insertion Operator (<<
):
This is used for output, often with std::cout
.
Here's a simple example using a Person
class:
#include <iostream>
class Person {
public:
Person(const std::string& name, int age) : name(name), age(age) {}
friend std::ostream& operator<<(std::ostream& os, const Person& person);
private:
std::string name;
int age;
};
std::ostream& operator<<(std::ostream& os, const Person& person) {
os << "Name: " << person.name << ", Age: " << person.age;
return os;
}
int main() {
Person p("John", 30);
std::cout << p << std::endl; // Outputs: Name: John, Age: 30
}
Notice a few things:
- The overloaded
<<
operator is afriend
function, not a member function. This is necessary because the left operand (thestd::ostream
object) isn't of typePerson
. - The function returns a reference to
std::ostream
. This allows chaining of the insertion operator (std::cout << p1 << p2;
).
3.2 Overloading the Extraction Operator (>>
):
This is used for input, often with std::cin
.
Let's extend the Person
class:
friend std::istream& operator>>(std::istream& is, Person& person);
// ...
std::istream& operator>>(std::istream& is, Person& person) {
std::cout << "Enter name: ";
is >> person.name;
std::cout << "Enter age: ";
is >> person.age;
return is;
}
// In main():
Person p2("", 0);
std::cin >> p2;
std::cout << p2 << std::endl;
Here:
- The overloaded
>>
operator is also afriend
function for the same reasons. - The function returns a reference to
std::istream
, allowing for chaining.
3.3 Important Points:
-
Always ensure that you're handling potential errors when dealing with input. The simple examples here don't handle erroneous inputs for brevity, but in a real application, always validate the input.
-
When overloading these operators for your classes, remember to maintain const correctness (notice the
const
in the<<
operator overload for thePerson
object). -
You might encounter scenarios where these operators are overloaded as member functions, especially for insertion/extraction of custom stream types. However, for the standard
std::ostream
andstd::istream
, they are typically overloaded as non-member (often friend) functions.
4. Lvalues, Rvalues, and Universal References
4.1 Lvalues vs Rvalues
I highly recommend watching The Cherno's video on lvalue/rvalue.
lvalue (Left value)
- An object that occupies a memory location and is addressable. Generally, if you can take the address of an expression using the
&
operator, it's an lvalue. - Typically appears on the left-hand side of an assignment. But it can also appear on the right side.
- Examples:
int x = 10; // 'x' is an lvalue
int& ref = x; // 'ref' is an lvalue reference to x
- lvalues can be const, making them unmodifiable.
const int y = 20; // 'y' is a const lvalue.
rvalue (Right value)
- A temporary value which is not associated with a persistent memory location.
- Can only appear on the right-hand side of an assignment.
- Examples:
int z = x + y; // 'x + y' is an rvalue
- Most constants, like literals, are rvalues (though there are constant lvalues as well).
int w = 5; // '5' is an rvalue
4.2 Constant References
Binding to lvalues
A const
reference can bind to an lvalue without any surprises because that's one of the primary purposes of references. When you bind a const
reference to an lvalue, you're simply promising not to modify that lvalue through that particular reference.
int x = 42;
int& ref = x; // okay
const int& cref = x; // also okay
Binding to rvalues
One of the special features of const
references in C++ is their ability to bind to rvalues. When a const
reference binds to an rvalue, the lifetime of that rvalue is extended to match the lifetime of the reference.
This means that, even though rvalues are typically temporary values that would be destroyed at the end of the expression they are in, if you bind them to a const
reference, they stick around for as long as the reference does.
int& ref = 42; // error: cannot bind non-const lvalue reference to rvalue
const int& cref2 = 42; // binds to rvalue '42'
// The above is equivalent to:
const int temp = 42; // create a temporary variable
const int& cref2 = temp; // bind to the temporary variable
Why const
references can bind to both
-
Flexibility and Safety: Allowing
const
references to bind to both lvalues and rvalues offers a good balance between flexibility and safety. This makes it easier to write functions that can accept a wide range of arguments without causing unintended side effects. -
Temporary Lifetime Extension: As mentioned, when a
const
reference binds to a temporary (an rvalue), the lifetime of that temporary is extended. This is a deliberate feature of the language to make it more usable. -
Efficiency: If a function only requires read-access to an object, accepting it by
const
reference can avoid expensive copies, especially for large objects. The ability to bind to temporaries is especially useful for passing literals or the results of expressions.
For instance, consider a function:
void printString(const std::string& text) {
std::cout << text << std::endl;
}
Thanks to const
references, you can call this function in multiple ways:
std::string hello = "Hello, World!";
printString(hello); // passing an lvalue
printString("Hello, World!"); // passing a string literal rvalue
printString(hello + ", Again!"); // passing an rvalue result of an expression
4.3 Why do we care?
References (&
and &&
)
- C++ allows you to create references to lvalues (
&
) and rvalues (&&
).
int x = 10;
int& lref = x; // lvalue reference (or just "reference")
int&& rref = 10; // rvalue reference
- As mentioned earlier, const lvalue references can bind to both lvalues and rvalues, which is especially useful for function arguments:
void foo(const int& arg) {} // Can accept both lvalues and rvalues
foo(x); // Pass lvalue
foo(10); // Pass rvalue
Move Semantics
- Traditional copy operations can be inefficient because they involve duplicating data. With rvalues and rvalue references, C++ introduced move semantics, which allows resources to be "moved" from one object to another without copying. Since rvalues are temporary and won't be used again, no one is going to "miss" them, so this is safe and efficient.
- Example:
std::string a = "Hello, ";
std::string b = "World!";
a = std::move(b); // 'b' is now in an unspecified (but valid) state
std::forward
: In template functions, to maintain the "rvalueness" or "lvalueness" of arguments,std::forward
is used.
template<typename T>
void wrapper(T&& arg) {
foo(std::forward<T>(arg));
}
4.4 Universal References:
- A term (not official in C++ standard) describing a type of reference in templated code that can bind to both lvalues and rvalues. It's represented by
T&&
in template context.
template<typename T>
void bar(T&& universalRef) {}
- Depending on how you call
bar
:
int x = 10;
bar(x); // Here, T&& becomes int& (lvalue reference)
bar(10); // Here, T&& remains as int&& (rvalue reference)
Why use Universal References?
While const
references provide the ability to bind to both lvalues and rvalues, they aren't suitable for all scenarios, especially when forwarding arguments or when mutation is involved. Universal references fill this gap by providing more flexibility in certain advanced scenarios. Here are several reasons why you might want to use universal references:
-
Mutation:
const
references, as the name suggests, are "constant". You can't modify the values they refer to. However, there might be cases where you'd want to modify rvalues or pass them to functions that expect non-const references. Universal references allow this. -
Perfect Forwarding: This is a common use case in templated code where you want to forward an argument to another function while retaining its original lvalue/rvalue nature. With
const
references, you can't forward an rvalue as a non-const rvalue, which limits its usability in generic code.
Consider the following:
template<typename Func, typename Arg>
void forwarder(Func&& f, Arg&& arg) {
f(std::forward<Arg>(arg)); // Perfectly forwards 'arg' to 'f'
}
Here, forwarder
can forward its argument to function f
as exactly the type it was given. If you pass an lvalue, it forwards as an lvalue. If you pass an rvalue, it forwards as an rvalue.
-
Efficiency with Move Semantics: Universal references can be used to efficiently handle resources by leveraging move semantics. For instance, you might want to write a generic container or utility that can accept both lvalues (without moving from them) and rvalues (to steal/move their resources). A
const
reference won't let you steal resources from rvalues since you can't modify what they point to. -
Overloading Resolution: In advanced cases, you might have multiple overloads of a function. Using universal references can help in disambiguating overloads and ensuring the right version of a function gets called based on the type and value category of the arguments.
5. constexpr
The constexpr
keyword, introduced in C++11, stands for "constant expression". It indicates that the value of a variable or the result of a function can be computed at compile-time. By doing so, it can improve performance and guarantee immutability.
5.1 Working Under the Hood:
-
Compile-time Evaluation: When you declare a variable or function as
constexpr
, the compiler tries to evaluate it at compile-time rather than runtime. If it cannot be evaluated at compile time, the compiler will raise an error. -
Immutable: Variables declared with
constexpr
are implicitlyconst
. They cannot be changed after their initial assignment. -
Restrictions:
constexpr
functions have some limitations. Introduced below:
5.2 Properties and Bad-Examples for constexpr
Functions:
- Return Type:
- Rule: The function must have a return type (i.e., it can't be
void
). - Bad Example (will result in compiler error):
- Rule: The function must have a return type (i.e., it can't be
constexpr void invalidFunc() {}
- Literal Types:
- Rule: The return type and all parameter types must be literal types. This means no complex user-defined types with virtual functions, no
const
orvolatile
members, and novirtual
base classes. - Bad Example (will result in compiler error):
- Rule: The return type and all parameter types must be literal types. This means no complex user-defined types with virtual functions, no
struct NonLiteralType {
virtual ~NonLiteralType() {} // virtual function makes it non-literal
};
constexpr NonLiteralType invalidFunc() { return NonLiteralType(); }
- Function Body Restrictions:
- Rule: Before C++14, only a single return statement and declarations. Post-C++14, more relaxed but still must meet constant expression requirements.
- Bad Example (will result in compiler error):
constexpr int invalidFunc(int x) {
int y = x + 1;
y += 3; // multiple statements (fine post C++14, not before)
return y;
}
- No Static or Thread-local Variables:
- Rule: No local
static
orthread_local
. - Bad Example (will result in compiler error):
- Rule: No local
constexpr int invalidFunc() {
static int x = 5;
return x;
}
- No Assembly:
- Rule: The function cannot contain any assembly code.
- Bad Example (will result in compiler error):
constexpr int invalidFunc() {
__asm__("NOP"); // pseudocode for assembly, varies by compiler
return 5;
}
- No Non-constexpr Calls:
- Rule: Functions it calls must also be
constexpr
. - Bad Example (will result in compiler error):
- Rule: Functions it calls must also be
int nonConstexprFunc() { return 42; }
constexpr int invalidFunc() {
return nonConstexprFunc();
}
- No Dynamic Memory:
- Rule: No use of
new
ordelete
. - Bad Example (will result in compiler error):
- Rule: No use of
constexpr int* invalidFunc() {
return new int(5);
}
- No Exception Handling:
- Rule: No
try
,catch
, orthrow
. - Bad Example (will result in compiler error):
- Rule: No
constexpr int invalidFunc() {
try {
throw "error";
} catch(...) {
return -1;
}
return 5;
}
- No Undefined Behavior:
- Rule: Must not produce undefined behavior.
- Bad Example (will result in compiler error):
constexpr int invalidFunc() {
int* ptr = nullptr;
return *ptr; // Dereferencing null pointer
}
- No
reinterpret_cast
:- Rule: Cannot use
reinterpret_cast
. - Bad Example (will result in compiler error):
- Rule: Cannot use
constexpr int invalidFunc(double d) {
return reinterpret_cast<int&>(d);
}
- No Mutable State:
- Rule: Cannot modify member data unless
mutable
. - Bad Example (will result in compiler error):
- Rule: Cannot modify member data unless
struct MyClass {
int x;
constexpr void invalidFunc() {
x = 5; // Modifying non-mutable member
}
};
5.3 Why and When to Use?
-
Performance: By resolving values at compile-time, you can eliminate certain runtime computations, thus potentially speeding up your program.
-
Safety:
constexpr
guarantees that the value is constant and known at compile-time. This can be helpful for array dimensions, template arguments, and other scenarios where compile-time known values are required. -
Code Clarity: When someone reads
constexpr
in your code, it signals that the variable or function result is a compile-time constant.
5.4 Why Not Just const
?
-
Scope:
const
means that a variable cannot be modified after initialization, but it doesn't guarantee that the value of the variable is known at compile-time.constexpr
, on the other hand, ensures that the value can be and is evaluated at compile-time. -
Functional Difference: You can have a
const
variable that is initialized at runtime. But aconstexpr
variable must be initialized with a compile-time constant value. -
Usage Scenarios:
const
is more flexible as it allows runtime initialization. However, for scenarios where compile-time values are crucial (like in template metaprogramming or array sizes),constexpr
is necessary.
5.5 Key Takeaways:
constexpr
enforces compile-time evaluation.- It provides both performance benefits and ensures safety by ensuring some values/operations are determined/resolved at compile-time.
- While
const
andconstexpr
both imply immutability,constexpr
adds the additional guarantee of compile-time evaluation.