C++ · lesson 7 of 24
Beginner
6Intermediate
11- 5 Operators and Operator Overloading in C++
- 8 Classes and Object-Oriented Programming in C++
- 9 Inheritance and Polymorphism in C++
- 11 STL Containers in C++
- 12 STL Algorithms in C++
- 13 Smart Pointers in C++
- 15 Lambdas and Functional Programming in C++
- 18 Error Handling in C++
- 19 File I/O in C++
- 23 C++ Interview Preparation: Top 35 Questions
- 24 Static Members in C++
Advanced
7Functions 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
-
Add
void print(const char* v)tooverload_bool.cpp. Which overload doesprint("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 includesbool. -
Change
connectso thetlsflag comes beforeport, with defaults intact, and call it asconnect("example.com", false). What does it print?Solution
http://example.com:443. Now the second positional argument istls, sofalselands there andportkeeps its default. Order parameters by how often callers override them. -
Write a
constexpr int fib(int n)with a loop,static_assert(fib(10) == 55), and astd::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 -
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. -
Make
suminvariadic.cppreject 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
Firstparameter meanssum()has no matching overload, and the fold starts from the first argument instead of an artificial 0.