Functions in C++
Master function overloading, default arguments, inline functions, constexpr functions, and function templates.
Functions in C++
Functions are the fundamental unit of code reuse in C++. They let you name a piece of logic, hide its implementation, and call it from many places. Beyond the basics, C++ gives you overloading (multiple functions with the same name for different types), default arguments (optional parameters), constexpr (compile-time evaluation), and templates (generic functions that work for any type). Together these tools let you write functions that are both expressive and efficient.
Declaration vs Definition
A declaration tells the compiler a function exists and describes its signature. A definition provides the actual body. Separating them is what makes header files work: headers declare, .cpp files define. The One Definition Rule (ODR) says a function can be declared many times but defined exactly once across the whole program.
// Declaration (in a header file) — tells callers what the function looks like
int add(int a, int b);
// Definition (in a .cpp file) — the actual implementation
int add(int a, int b) {
return a + b;
}
Forward declarations let you call functions before their definition appears in the file, which is why you can call functions defined later in the same .cpp file by declaring them at the top.
Function Overloading
Overloading solves the problem of having the same logical operation that works on different types. Without overloading you’d need print_int, print_double, print_string — with overloading you get a single print that the compiler routes to the right implementation based on the argument type, at compile time with zero overhead.
#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); // calls print(int)
print(3.14); // calls print(double)
print("hello"); // calls print(const std::string&)
}
Return type alone cannot distinguish overloads — the difference must be in the parameter types or count.
Default Arguments
Default arguments let you design functions that are simple in the common case but flexible for advanced use. A function with three parameters, two of which have sensible defaults, can be called with one argument by most callers while still being fully configurable when needed.
#include <string>
void connect(const std::string& host,
int port = 443,
bool useTLS = true) {
// ...
}
int main() {
connect("example.com"); // uses port=443, useTLS=true
connect("example.com", 8080); // uses useTLS=true
connect("example.com", 80, false);
}
Default arguments must appear at the end of the parameter list. Declare defaults in the header; don’t repeat them in the .cpp definition.
Inline Functions
The inline keyword suggests to the compiler that it replace call sites with the function body, eliminating call overhead. Modern compilers inline aggressively based on their own heuristics regardless of the keyword, so the performance hint is rarely needed. Its main practical use today is enabling function definitions in header files without violating the ODR — each translation unit that includes the header gets its own copy, and inline tells the linker that’s intentional.
// math_utils.h — safe to include in multiple .cpp files
// without inline, multiple definitions of square() would cause a linker error
inline int square(int x) {
return x * x;
}
Mark small, frequently-called accessors inline when they live in headers.
constexpr Functions
A constexpr function can be evaluated at compile time when given constant arguments, producing a compile-time constant with zero runtime cost. This is powerful because it lets you use function-call syntax for things that would otherwise require macros or template metaprogramming, while getting full type safety and debuggability.
#include <array>
constexpr int factorial(int n) {
return (n <= 1) ? 1 : n * factorial(n - 1);
}
constexpr double circleArea(double r) {
return 3.14159265358979 * r * r;
}
int main() {
// Computed entirely at compile time — no runtime cost
constexpr int f5 = factorial(5); // 120
constexpr double area = circleArea(5.0); // ~78.54
// The compile-time value can be used anywhere a constant is required
std::array<int, factorial(4)> arr; // std::array<int, 24>
}
Since C++14, constexpr functions can contain local variables, loops, and conditionals.
Passing by Value, Reference, and Const Reference
How you pass arguments affects both correctness (does the function see the caller’s data?) and performance (does it make unnecessary copies?). Getting this right is one of the most frequently-asked questions in C++ code review.
#include <string>
#include <vector>
// By value — caller's copy is unaffected; efficient for small types (int, double)
void byValue(int x) { x = 99; } // modifies local copy only
// By reference — modifies the caller's variable directly
void byRef(int& x) { x = 99; } // caller sees the change
// By const reference — read access without copy; ideal for large types
// std::string can be megabytes — you don't want to copy it on every call
void byConstRef(const std::string& s) {
// read s without copying it
// cannot modify it — compiler enforces this
}
// By rvalue reference — "steal" resources from temporaries
// Used in move constructors and move assignment operators
void byRvalueRef(std::vector<int>&& v) {
auto owned = std::move(v); // v is now empty; owned has the data
}
Rule of thumb: pass scalars by value, pass large objects by const&, pass output parameters by &.
Trailing Return Type
When the return type depends on the parameter types in a way that can’t be expressed before the parameters are named, write the return type after the parameter list using ->. This is most common in template functions.
#include <type_traits>
// Return type is the type of a + b — can't write that before the parameters
auto add(auto a, auto b) -> decltype(a + b) {
return a + b;
}
C++14 and later allow auto return type with full deduction, making this less necessary in simple cases.
[[nodiscard]]
Mark functions whose return value must not be ignored. This prevents a common category of bug where a caller forgets to use an error code or a handle returned by a factory function.
[[nodiscard]] int openFile(const char* path);
[[nodiscard("check for allocation failure")]] void* allocate(std::size_t bytes);
int main() {
openFile("data.txt"); // compiler warning: ignoring return value
}
Apply [[nodiscard]] to error codes, resource handles, and any function whose return value carries essential information.
Function Templates (Basics)
When you would write multiple overloads with identical bodies differing only in type, a template is the right tool. The compiler generates a concrete function for each unique set of argument types you actually use — so you get type safety and efficiency without writing the same code multiple times.
#include <algorithm>
template <typename T>
T clamp(T value, T lo, T hi) {
return std::max(lo, std::min(value, hi));
}
int main() {
int a = clamp(15, 0, 10); // 10 — compiler generates clamp<int>
float b = clamp(0.5f, 1.0f, 2.0f); // 1.0f — compiler generates clamp<float>
}
Template argument deduction usually means you never have to write clamp<int>(...) explicitly — the compiler infers T from the argument types.
Variadic Functions (Basics)
C-style variadic functions use ... and <cstdarg>, but they bypass the type system entirely — unsafe and error-prone. In modern C++, use variadic templates or std::initializer_list instead, which are both type-safe and work with range-based for loops.
#include <initializer_list>
#include <numeric>
// Type-safe variadic function using initializer_list
double average(std::initializer_list<double> values) {
return std::accumulate(values.begin(), values.end(), 0.0)
/ values.size();
}
int main() {
double avg = average({1.0, 2.0, 3.0, 4.0}); // 2.5
}
This approach is type-safe and works naturally with range-based for loops.