Skip to main content
C++ · lesson 7 of 24
C++ beginner Lesson 7 of 24

Functions in C++

C++ functions with real g++ output: declarations across files, overload surprises, default arguments, constexpr/consteval, passing and templates.

By SkillByExample · Updated

Functions let you name logic and reuse it. C++ adds overloading, default arguments, compile-time evaluation and templates on top of that.

Declaration vs Definition

A declaration announces a signature; a definition supplies the body. Headers hold declarations, and each function is defined exactly once across the program — the One Definition Rule.

// math_utils.h
#pragma once

int add(int a, int b);                 // declaration: defined in math_utils.cpp

inline int square(int x) {             // definition in a header: needs inline
    return x * x;
}
// math_utils.cpp
#include "math_utils.h"

int add(int a, int b) { return a + b; }
// report.cpp: a second file that also includes the header
#include "math_utils.h"

int area(int side) { return square(side); }
// main.cpp
#include <iostream>
#include "math_utils.h"

int area(int side);                    // declared here, defined in report.cpp
int twice(int n);                      // forward declaration: defined below main

int main() {
    std::cout << add(2, 3) << " " << square(4) << " " << area(5) << " " << twice(21) << "\n";
}

int twice(int n) { return add(n, n); }
$ g++ -std=c++20 -Wall -Wextra -o app main.cpp math_utils.cpp report.cpp
$ ./app
5 16 25 42

main.cpp only sees declarations for add, area and twice, and that’s enough to compile a call; the linker connects each call to the one definition. twice is used before its body appears, which works because of the forward declaration at the top. square is defined in the header, so both main.cpp and report.cpp contain a copy — that’s allowed only because it’s inline. Remove the keyword and the linker objects:

$ sed -i 's/^inline //' math_utils.h
$ g++ -std=c++20 -Wall -Wextra -o app main.cpp math_utils.cpp report.cpp 2>&1 | grep -E 'multiple definition|error'
math_utils.cpp:(.text+0x0): multiple definition of `square(int)'; /tmp/cchlaZEz.o:main.cpp:(.text+0x0): first defined here
report.cpp:(.text+0x0): multiple definition of `square(int)'; /tmp/cchlaZEz.o:main.cpp:(.text+0x0): first defined here
collect2: error: ld returned 1 exit status

Each file compiled fine on its own; the error only appears when the linker sees three definitions of square(int). (The /tmp/cc*.o names are the compiler’s temporary object files.) That’s the real job of inline today — compilers decide what to inline for speed without being asked.

Function Overloading

Several functions can share a name if their parameters differ. The compiler picks one at compile time by ranking conversions.

#include <iostream>
#include <string>

void print(int v)                { std::cout << "int: " << v << "\n"; }
void print(double v)             { std::cout << "double: " << v << "\n"; }
void print(const std::string& v) { std::cout << "string: " << v << "\n"; }

int main() {
    print(42);          // exact match: int
    print(3.14);        // exact match: double
    print(3.14f);       // float -> double is a promotion, beats float -> int
    print('A');         // char -> int is a promotion
    print("hello");     // const char[6] -> std::string (user-defined conversion)
}
$ g++ -std=c++20 -Wall -Wextra -o overload overload.cpp
$ ./overload
int: 42
double: 3.14
double: 3.14
int: 65
string: hello

3.14f has no float overload, but float→double is a promotion, which ranks above float→int. 'A' promotes to int and prints 65. "hello" reaches the std::string overload through a user-defined conversion — the worst rank, chosen only because nothing better exists. Add a better match and it loses:

#include <iostream>
#include <string>

void print(bool v)               { std::cout << "bool: " << v << "\n"; }
void print(const std::string& v) { std::cout << "string: " << v << "\n"; }

int main() {
    print("hello");                  // which one?
    print(std::string("hello"));
    using namespace std::string_literals;
    print("hello"s);                 // "..."s is already a std::string
}
$ g++ -std=c++20 -Wall -Wextra -o overload_bool overload_bool.cpp
$ ./overload_bool
bool: 1
string: hello
string: hello

A string literal decays to const char*, and pointer→bool is a standard conversion, which beats any user-defined one — so print("hello") prints bool: 1. Only an actual std::string (constructed explicitly, or the "hello"s literal) reaches the string overload. Return type never participates: two functions differing only in return type won’t compile.

Default Arguments

Defaults let common calls stay short. They fill in from the right, and a default can be any expression — evaluated at each call.

#include <iostream>
#include <string>

void connect(const std::string& host, int port = 443, bool tls = true) {
    std::cout << (tls ? "https://" : "http://") << host << ":" << port << "\n";
}

int next_id() {
    static int id = 0;
    return ++id;
}

void log(const std::string& msg, int id = next_id()) {   // evaluated at each call
    std::cout << "#" << id << " " << msg << "\n";
}

int main() {
    connect("example.com");
    connect("example.com", 8080);
    connect("example.com", 80, false);
    log("first");
    log("second");
    log("pinned", 99);
    log("third");
}
$ g++ -std=c++20 -Wall -Wextra -o defaults defaults.cpp
$ ./defaults
https://example.com:443
https://example.com:8080
http://example.com:80
#1 first
#2 second
#99 pinned
#3 third

connect needs only a host; you can override port alone, but to change tls you must also pass port, because arguments are positional. The log output shows that the default next_id() runs at every call that omits id: 1, 2, then the explicit 99 skipped the call, so the next default is 3, not 4. Put defaults on the declaration in the header, not repeated on the definition.

constexpr and consteval Functions

A constexpr function runs at compile time when it can and at runtime when it must. consteval (C++20) insists on compile time.

#include <array>
#include <iostream>

constexpr long factorial(int n) {
    long result = 1;
    for (int i = 2; i <= n; ++i) result *= i;   // loops are fine since C++14
    return result;
}

consteval int kib(int n) { return n * 1024; }   // C++20: compile time only

int main() {
    constexpr long f5 = factorial(5);
    static_assert(factorial(10) == 3'628'800);
    std::array<int, factorial(4)> arr{};        // size 24, fixed at compile time

    int n;
    std::cin >> n;
    std::cout << "f5 = " << f5 << ", arr.size() = " << arr.size()
              << ", factorial(" << n << ") = " << factorial(n)   // runtime call
              << ", kib(4) = " << kib(4) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o constexpr_fn constexpr_fn.cpp
$ echo 12 | ./constexpr_fn
f5 = 120, arr.size() = 24, factorial(12) = 479001600, kib(4) = 4096

One factorial served three roles: static_assert checked it during compilation, std::array used it as a size, and the value read from stdin (12) was computed at runtime. A consteval function has no runtime version at all:

consteval int kib(int n) { return n * 1024; }

int main(int argc, char**) {
    return kib(argc);    // argc is only known at runtime
}
$ g++ -std=c++20 -Wall -Wextra -o consteval_fail consteval_fail.cpp 2>&1 | grep error
consteval_fail.cpp:4:15: error: 'argc' is not a constant expression

Passing Arguments

How you pass decides whether the function sees the caller’s object and whether anything is copied.

#include <iostream>
#include <string>
#include <utility>
#include <vector>

void by_value(int x) { x = 99; std::cout << "inside by_value: " << x << "\n"; }
void by_ref(int& x)               { x = 99; }
std::size_t by_cref(const std::string& s) { return s.size(); }   // no copy
void by_rvalue(std::vector<int>&& v) {
    std::vector<int> owned = std::move(v);          // steal the buffer
    std::cout << "sink took " << owned.size() << " elements\n";
}

int main() {
    int a = 1, b = 1;
    by_value(a);
    by_ref(b);
    std::cout << "a = " << a << ", b = " << b << "\n";

    std::string big(1'000'000, 'x');
    std::cout << "length via const&: " << by_cref(big) << "\n";

    std::vector<int> data = {1, 2, 3};
    by_rvalue(std::move(data));
    std::cout << "data.size() after move: " << data.size() << "\n";
    by_rvalue({4, 5});                              // a temporary binds directly
}
$ g++ -std=c++20 -Wall -Wextra -o passing passing.cpp
$ ./passing
inside by_value: 99
a = 1, b = 99
length via const&: 1000000
sink took 3 elements
data.size() after move: 0
sink took 2 elements

by_value changed its own copy to 99 and a stayed 1; by_ref changed b itself. by_cref read a million-character string without copying it. by_rvalue accepts only rvalues, so passing data needs std::move(data) — and afterwards data is empty (in practice; the standard only promises a “valid but unspecified” state). A braced temporary binds directly with no move. Rule of thumb: scalars by value, big read-only objects by const&, outputs by &.

[[nodiscard]]

[[nodiscard]] turns an ignored return value into a warning, and C++20 lets you attach a reason.

#include <cstdio>

[[nodiscard("check the error code")]] int save(const char* path) {
    return path[0] == '\0' ? -1 : 0;
}

int main() {
    save("data.txt");                 // warning: result ignored
    if (save("") != 0) std::puts("save failed");
}
$ g++ -std=c++20 -Wall -Wextra -o nodiscard_msg nodiscard_msg.cpp
nodiscard_msg.cpp: In function 'int main()':
nodiscard_msg.cpp:8:9: warning: ignoring return value of 'int save(const char*)', declared with attribute 'nodiscard': 'check the error code' [-Wunused-result]
    8 |     save("data.txt");                 // warning: result ignored
      |     ~~~~^~~~~~~~~~~~
nodiscard_msg.cpp:3:43: note: declared here
    3 | [[nodiscard("check the error code")]] int save(const char* path) {
      |                                           ^~~~
$ ./nodiscard_msg
save failed

The warning quotes the message, so the reader knows why the result matters. The second call uses the result, so it’s silent, and the program runs normally.

Function Templates

When overloads would have identical bodies, write one template. The compiler deduces T from the arguments.

#include <iostream>
#include <string>

template <typename T>
T clamp(T value, T lo, T hi) {
    return value < lo ? lo : (hi < value ? hi : value);
}

int main() {
    std::cout << clamp(15, 0, 10) << "\n";                   // T = int
    std::cout << clamp(0.5, 1.0, 2.0) << "\n";               // T = double
    std::cout << clamp<std::string>("pear", "apple", "fig") << "\n";
    std::cout << clamp<double>(15, 0, 9.5) << "\n";          // explicit T
}
$ g++ -std=c++20 -Wall -Wextra -o clamp clamp.cpp
$ ./clamp
10
1
fig
9.5

clamp(15, 0, 10) deduced int, and clamp(0.5, 1.0, 2.0) deduced double. String literals would deduce const char* and compare pointer addresses, so clamp<std::string> names the type explicitly. The last call also specifies T, because deduction requires every argument to agree:

template <typename T>
T clamp(T value, T lo, T hi) {
    return value < lo ? lo : (hi < value ? hi : value);
}

int main() {
    return clamp(15, 0, 9.5);   // int, int, double: what is T?
}
$ g++ -std=c++20 -Wall -Wextra -o clamp_fail clamp_fail.cpp 2>&1 | grep -E 'error|note:.*deduced'
clamp_fail.cpp:7:17: error: no matching function for call to 'clamp(int, int, double)'
clamp_fail.cpp:7:17: note:   deduced conflicting types for parameter 'T' ('int' and 'double')

T would be int from the first two arguments and double from the third, so deduction fails. clamp<double>(15, 0, 9.5) resolves it by converting the ints.

Variable Numbers of Arguments

C’s ... bypasses type checking. Modern C++ uses std::initializer_list for many values of one type and variadic templates for mixed types.

#include <initializer_list>
#include <iostream>
#include <numeric>
#include <string>

double average(std::initializer_list<double> values) {
    return std::accumulate(values.begin(), values.end(), 0.0) / values.size();
}

template <typename... Args>
auto sum(Args... args) {
    return (args + ... + 0);            // C++17 fold expression
}

template <typename... Args>
void print_all(const Args&... args) {
    ((std::cout << args << " "), ...);  // one << per argument, any types
    std::cout << "(" << sizeof...(args) << " args)\n";
}

int main() {
    std::cout << average({1.0, 2.0, 3.0, 4.0}) << "\n";
    std::cout << sum(1, 2, 3) << " " << sum(1, 2.5) << " " << sum() << "\n";
    print_all(42, "text", 3.5, std::string("str"), 'c');
}
$ g++ -std=c++20 -Wall -Wextra -o variadic variadic.cpp
$ ./variadic
2.5
6 3.5 0
42 text 3.5 str c (5 args)

average takes any number of doubles in braces. sum is a variadic template whose fold expression (args + ... + 0) expands to 1 + (2 + (3 + 0)); the + 0 gives an answer for sum() with no arguments, which would otherwise be a compile error. print_all folds over the comma operator to stream five arguments of five different types, and sizeof...(args) counts them.

Practice

  1. Add void print(const char* v) to overload_bool.cpp. Which overload does print("hello") pick now?

    Solution

    print(const char*) — an exact match after array-to-pointer decay beats both the bool conversion and the string conversion. This is the usual fix when an overload set includes bool.

  2. Change connect so the tls flag comes before port, with defaults intact, and call it as connect("example.com", false). What does it print?

    Solution

    http://example.com:443. Now the second positional argument is tls, so false lands there and port keeps its default. Order parameters by how often callers override them.

  3. Write a constexpr int fib(int n) with a loop, static_assert(fib(10) == 55), and a std::array<int, fib(6)>.

    Solution
    constexpr int fib(int n) {
        int a = 0, b = 1;
        for (int i = 0; i < n; ++i) { int t = a + b; a = b; b = t; }
        return a;
    }
    static_assert(fib(10) == 55);
    std::array<int, fib(6)> arr{};   // size 8
  4. Write a template <typename T> void swap_values(T& a, T& b) and use it on two ints and two strings.

    Solution
    template <typename T>
    void swap_values(T& a, T& b) {
        T tmp = std::move(a);
        a = std::move(b);
        b = std::move(tmp);
    }

    Parameters must be references — by value, the function would swap its own copies and the caller would see nothing, exactly like by_value.

  5. Make sum in variadic.cpp reject calls with no arguments at compile time instead of returning 0.

    Solution
    template <typename First, typename... Rest>
    auto sum(First first, Rest... rest) {
        return (first + ... + rest);
    }

    Requiring a First parameter means sum() has no matching overload, and the fold starts from the first argument instead of an artificial 0.

Frequently Asked Questions

When should I use inline functions in C++?
Use inline when a function is defined in a header that several .cpp files include. Without it, each file gets its own definition and the linker reports multiple definitions. Compilers decide what to inline for speed on their own, so the keyword is about linkage, not performance.
What is the difference between function overloading and templates?
Overloading gives several hand-written implementations the same name, chosen by argument types. A template is one blueprint the compiler instantiates for each type it is used with. Use templates when the bodies would be identical, overloads when the logic differs per type.
Why does a string literal call my bool overload instead of std::string?
A string literal is a const char array that decays to a pointer, and pointer-to-bool is a standard conversion, which overload resolution ranks above the user-defined conversion to std::string. Pass std::string(...) or use the "..."s literal to get the string overload.
How should I pass arguments to a C++ function?
Pass small types such as int and double by value, large read-only objects by const reference, objects the function must modify by non-const reference, and objects the function will take ownership of by value or rvalue reference so callers can std::move into it.
What is the difference between constexpr and consteval functions?
A constexpr function may run at compile time when its arguments are constants and at runtime otherwise. A consteval function (C++20) must run at compile time: calling it with a runtime value is a compile error.