Functions
Parameters, returns, overloads, and the cost of each calling convention.
By the end of this chapter you can
- Choose between passing by value, by reference, and by const reference
- Explain what overload resolution picks and why
- Write a function with a default argument without creating ambiguity
A function is a named piece of work with a stated interface. In C++ that interface says more than in most languages: it says what the caller gives up, what it gets back, and whether its own objects can be modified.
Declaration and definition
#include <iostream>
// Declaration: the promise. Enough for callers to type-check against.
int add(int a, int b);
int main() {
std::cout << add(2, 3) << '\n';
}
// Definition: the body. May live in another file, which is why the
// declaration is enough for main to compile.
int add(int a, int b) {
return a + b;
}A declaration introduces the name and signature; a definition supplies the body. Chapter 1.1 showed what happens when you declare and never define: it compiles and the linker complains, because the promise was never kept.
Every function must state its return type. void means it returns nothing.
How parameters are passed
This is the decision C++ asks you to make that most languages do not.
#include <iostream>
#include <string>
void by_value(std::string s) { s += " (changed)"; }
void by_reference(std::string& s) { s += " (changed)"; }
void by_const_ref(const std::string& s) { std::cout << " read: " << s << '\n'; }
int main() {
std::string text = "hello";
by_value(text);
std::cout << "after by_value: " << text << '\n';
by_reference(text);
std::cout << "after by_reference: " << text << '\n';
by_const_ref(text);
}- By value (
std::string s) copies. The function gets its own object; changes do not escape. - By reference (
std::string& s) binds to the caller’s object. Changes are visible to the caller — that is the point. - By const reference (
const std::string& s) binds without copying, and the compiler enforces that the function does not modify it.
The default you want:
| Parameter is | Take it |
|---|---|
Small and cheap to copy (int, double, a pointer, std::string_view) |
by value |
| Larger, and you only read it | const T& |
| Something the caller expects you to modify | T& |
| Something you are taking ownership of | by value, then std::move |
The cost of getting it wrong is real:
#include <chrono>
#include <iostream>
#include <string>
std::size_t by_value(std::string s) { return s.size(); }
std::size_t by_const_ref(const std::string& s) { return s.size(); }
int main() {
const std::string big(200'000, 'x');
constexpr int rounds = 2000;
using clock = std::chrono::steady_clock;
using us = std::chrono::microseconds;
auto start = clock::now();
std::size_t sink = 0;
for (int i = 0; i < rounds; ++i) sink += by_value(big);
auto mid = clock::now();
for (int i = 0; i < rounds; ++i) sink += by_const_ref(big);
auto finish = clock::now();
std::cout << "by value: " << std::chrono::duration_cast<us>(mid - start).count() << " us\n";
std::cout << "by const ref: " << std::chrono::duration_cast<us>(finish - mid).count() << " us\n";
std::cout << "(checksum " << sink << ")\n";
}Same answer; one of them copied 200,000 bytes two thousand times to get it.
Returning
Return by value. The copy you might fear usually does not happen:
#include <iostream>
#include <string>
#include <vector>
std::vector<std::string> build() {
std::vector<std::string> result;
result.push_back("alpha");
result.push_back("beta");
return result; // no copy: elided, or moved
}
int main() {
const auto values = build();
std::cout << values.size() << " items, first is " << values[0] << '\n';
}Since C++17 the compiler is required to elide the copy in many cases, and where
it cannot elide, it moves. return result; is correct and fast; writing
return std::move(result); is worse, because it prevents the elision. Chapter
7.5 covers exactly when.
Overloading
Several functions may share a name if their parameters differ:
#include <iostream>
#include <string>
void print(int value) { std::cout << "int: " << value << '\n'; }
void print(double value) { std::cout << "double: " << value << '\n'; }
void print(const std::string& value) { std::cout << "string: " << value << '\n'; }
int main() {
print(42);
print(3.14);
print(std::string{"hello"});
print('a'); // char converts to int — the int overload wins
print(2.0f); // float converts to double
}The compiler picks by looking at the argument types and ranking the conversions
each candidate would need. An exact match beats a promotion (char →
int, float → double), which beats any other standard conversion
(int → double, int → long), which beats a user-defined conversion.
Note where float sits: print(2.0f) above chose print(double) not because
the two were tied, but because float → double is a promotion while float →
int is a mere conversion. Ranking, not coin-flipping.
A genuine tie is an ambiguity error rather than an arbitrary choice:
void handle(long) {}
void handle(double) {}
int main() {
// int → long and int → double are both plain conversions, ranked equally.
handle(1);
}The fix is to be explicit at the call site (handle(static_cast<double>(1.0f)))
or to add an overload that matches exactly.
Return type is not part of the signature. Two functions differing only in what they return is an error, not an overload:
int value() { return 1; }
double value() { return 1.0; }
int main() { return 0; }Default arguments
#include <iostream>
#include <string>
std::string join(const std::string& a, const std::string& b,
const std::string& separator = ", ") {
return a + separator + b;
}
int main() {
std::cout << join("alpha", "beta") << '\n';
std::cout << join("alpha", "beta", " | ") << '\n';
}Defaults must come last — everything after a defaulted parameter must also be defaulted, because arguments are matched positionally.
#include <string>
void log(const std::string& message, int level = 0) { (void)message; (void)level; }
void log(const std::string& message) { (void)message; }
int main() {
log("hello"); // both candidates match exactly
}Small functions and inline
A function call is not free — arguments are placed, control jumps, a frame is set up — but the compiler removes most of that for small functions by inlining them. You do not have to ask:
int square(int x) { return x * x; }
int caller(int a) {
return square(a) + square(a + 1);
}Press Assembly at -O2: there is no call to square at all. The
multiplications are inlined directly into caller.
The inline keyword is not primarily a performance hint — modern compilers
decide for themselves. Its real meaning is a linker rule: an inline function
may be defined in several translation units without violating the one-definition
rule, which is why functions defined in headers are marked inline. Chapter 9.1
covers that properly.
Write small, well-named functions. Chapter 7.2 makes the case with measurements; the short version is that the compiler is better at this than you are, and the readability is worth more than the call.