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

  1. Function name should be the same.
  2. Change in number, type (data type), or sequence of parameters.
  3. Return type is not considered while differentiating the overloaded functions, so functions cannot be overloaded only based on return type.

1.2 Examples

  1. 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
}
  1. 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
}
  1. 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 a friend function, not a member function. This is necessary because the left operand (the std::ostream object) isn't of type Person.
  • 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 a friend function for the same reasons.
  • The function returns a reference to std::istream, allowing for chaining.

3.3 Important Points:

  1. 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.

  2. When overloading these operators for your classes, remember to maintain const correctness (notice the const in the << operator overload for the Person object).

  3. 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 and std::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

  1. 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.

  2. 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.

  3. 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:

  1. 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.

  2. 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.

  1. 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.

  2. 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:

  1. 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.

  2. Immutable: Variables declared with constexpr are implicitly const. They cannot be changed after their initial assignment.

  3. Restrictions: constexpr functions have some limitations. Introduced below:

5.2 Properties and Bad-Examples for constexpr Functions:

  1. Return Type:
    • Rule: The function must have a return type (i.e., it can't be void).
    • Bad Example (will result in compiler error):
constexpr void invalidFunc() {}
  1. 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 or volatile members, and no virtual base classes.
    • Bad Example (will result in compiler error):
struct NonLiteralType {
    virtual ~NonLiteralType() {} // virtual function makes it non-literal
};

constexpr NonLiteralType invalidFunc() { return NonLiteralType(); }
  1. 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;
}
  1. No Static or Thread-local Variables:
    • Rule: No local static or thread_local.
    • Bad Example (will result in compiler error):
constexpr int invalidFunc() {
    static int x = 5;
    return x;
}
  1. 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;
}
  1. No Non-constexpr Calls:
    • Rule: Functions it calls must also be constexpr.
    • Bad Example (will result in compiler error):
int nonConstexprFunc() { return 42; }

constexpr int invalidFunc() {
    return nonConstexprFunc();
}
  1. No Dynamic Memory:
    • Rule: No use of new or delete.
    • Bad Example (will result in compiler error):
constexpr int* invalidFunc() {
    return new int(5);
}
  1. No Exception Handling:
    • Rule: No try, catch, or throw.
    • Bad Example (will result in compiler error):
constexpr int invalidFunc() {
    try {
        throw "error";
    } catch(...) {
        return -1;
    }
    return 5;
}
  1. 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
}
  1. No reinterpret_cast:
    • Rule: Cannot use reinterpret_cast.
    • Bad Example (will result in compiler error):
constexpr int invalidFunc(double d) {
    return reinterpret_cast<int&>(d); 
}
  1. No Mutable State:
    • Rule: Cannot modify member data unless mutable.
    • Bad Example (will result in compiler error):
struct MyClass {
    int x;
    constexpr void invalidFunc() {
        x = 5;  // Modifying non-mutable member
    }
};

5.3 Why and When to Use?

  1. Performance: By resolving values at compile-time, you can eliminate certain runtime computations, thus potentially speeding up your program.

  2. 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.

  3. 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?

  1. 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.

  2. Functional Difference: You can have a const variable that is initialized at runtime. But a constexpr variable must be initialized with a compile-time constant value.

  3. 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 and constexpr both imply immutability, constexpr adds the additional guarantee of compile-time evaluation.