C++ Functions: Parameters, Return Values, and Overloading
Key takeaways
Functions are the building blocks of C++ programs. This guide covers everything beginners need: how to define and call functions, when to pass by value vs reference, how overloading works, and the pitfalls to avoid.
Why Functions?
Functions give a name to a block of code so you can reuse it without copying. They break large programs into smaller, testable pieces.
// Without functions — repeated code
double area1 = 3.14159 * 5.0 * 5.0;
double area2 = 3.14159 * 3.0 * 3.0;
// With a function — write once, use anywhere
double circleArea(double radius) {
return 3.14159 * radius * radius;
}
double area1 = circleArea(5.0);
double area2 = circleArea(3.0);
Reuse is only half the benefit. A named function is also a unit you can test in isolation, a place to fix a bug once (if 3.14159 should become std::numbers::pi, there is one line to change), and documentation: circleArea(r) says what the arithmetic means. A useful rule of thumb is that a function should do one thing you can describe in its name without “and”; when a name needs “and”, it is usually two functions.
Function Anatomy
// Return type Name Parameters
double add (double a, double b) {
return a + b; // body
}
Every part matters:
- Return type: what type the function produces (
voidif nothing) - Name: what you call it
- Parameters: inputs (can have zero or many)
- Body: the code that runs
return: sends a value back to the caller
Declaration vs Definition
A declaration tells the compiler a function exists (its name, return type, parameter types). A definition provides the actual code. You can declare a function before defining it:
// Declaration (prototype) — put in header files or at top of file
double squareRoot(double x);
void printLine(const std::string& text);
// Use the function before the definition
int main() {
std::cout << squareRoot(16.0) << '\n'; // 4.0
printLine("Hello");
}
// Definition — the actual implementation
double squareRoot(double x) {
return std::sqrt(x);
}
void printLine(const std::string& text) {
std::cout << text << '\n';
}
In larger projects, declarations go in .h header files and definitions go in .cpp source files.
The reason for the split is that C++ compiles each .cpp file separately, top to bottom. When the compiler reaches squareRoot(16.0) inside main, it only needs to know the function’s signature to generate the call; the linker later connects that call to the definition, wherever it lives. That also explains the two distinct errors beginners meet. A missing declaration is a compile error ('squareRoot' was not declared in this scope). A declaration without any definition compiles fine and fails at link time with undefined reference to 'squareRoot(double)' (GCC/Clang) or LNK2019: unresolved external symbol (MSVC) — usually because a .cpp file was not added to the build. The opposite mistake, putting a full non-inline function definition in a header included by several .cpp files, produces multiple definition of errors at link time. This example also needs #include <cmath>, <iostream>, and <string> to compile.
Parameters: Value, Reference, and Pointer
Pass by Value
A copy of the argument is made. Changes inside the function don’t affect the caller:
void doubleIt(int x) {
x *= 2; // modifies the copy, not the caller's variable
}
int main() {
int n = 5;
doubleIt(n);
std::cout << n; // still 5
}
Use when: the type is small (int, double, char, small structs), or you intentionally want a local copy to modify.
Pass by Reference
An alias to the caller’s variable — no copy, changes affect the caller:
void doubleInPlace(int& x) { // & means reference
x *= 2; // modifies the caller's variable
}
int main() {
int n = 5;
doubleInPlace(n);
std::cout << n; // 10
}
Use when: you need to modify the caller’s variable (output parameter).
The call site doubleInPlace(n) looks exactly like a pass-by-value call, which is the main criticism of non-const references: a reader cannot tell from the call that n changes. For that reason many style guides prefer returning a value (n = doubled(n);) or a struct when a function produces results, and reserve non-const references for cases where modifying in place is the point, such as sorting a container or filling a large buffer. A non-const reference also cannot bind to a temporary: doubleInPlace(5) fails with cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int', which is the compiler protecting you from modifying something that disappears immediately.
Pass by const Reference
Efficient read-only access — no copy, but cannot be modified:
double sumVector(const std::vector<double>& v) { // const & — no copy, no modification
double total = 0;
for (double x : v) total += x;
return total;
}
Use when: the type is large (string, vector, any struct) and the function only reads it. This is the most common pattern for large types in C++.
Unlike a plain reference, const T& binds to temporaries and to values that need conversion, so sumVector({1.0, 2.0}) works. Two modern refinements are worth knowing. For read-only text, std::string_view (C++17) accepts std::string, string literals, and substrings without constructing a std::string, which a const std::string& parameter would do for every literal passed. And when a function is going to keep a copy of its argument — storing it in a member or a container — taking the parameter by value and moving from it (void setName(std::string name) { name_ = std::move(name); }) lets callers pass temporaries without any copy at all, while const std::string& would force one copy inside the function.
Pass by Pointer
Similar to reference, but allows nullptr (no argument):
void process(const std::string* text) {
if (text == nullptr) {
std::cout << "(nothing)\n";
return;
}
std::cout << *text << '\n';
}
int main() {
std::string s = "hello";
process(&s); // pass address
process(nullptr); // no argument
}
Use when: null is a valid state (optional parameter). For optional values that are cheap to copy, std::optional<T> is clearer in modern C++; for optional access to a large existing object, a pointer remains the idiomatic choice, because std::optional<const T&> is not available before C++26. Either way, the function must check for “no value” before using it — dereferencing a null pointer is undefined behavior and typically crashes with a segmentation fault.
Quick Reference
| Type | Syntax | Copy? | Modifiable? | Use for |
|---|---|---|---|---|
| Value | int x | Yes | Local only | Small types |
| Const ref | const T& x | No | No | Large read-only types |
| Ref | T& x | No | Yes | Output parameters |
| Pointer | T* x | No | Yes | Optional parameters |
Return Values
Returning a Value
int add(int a, int b) {
return a + b;
}
std::string greet(const std::string& name) {
return "Hello, " + name + "!";
}
Every non-void function must return a value on all code paths. Modern compilers warn or error when a path exits without returning.
In C++ this is more serious than a style issue: flowing off the end of a non-void function (other than main) is undefined behavior, not “returns garbage”. Optimizing compilers are allowed to assume that path never happens, and the observed effects range from random return values to the function falling through into unrelated code. GCC reports it as control reaches end of non-void function [-Wreturn-type] — a warning by default, which is why many teams compile with -Werror=return-type.
Returning Large Objects (RVO)
You can return large objects by value — the compiler eliminates the copy:
std::vector<int> makeRange(int start, int end) {
std::vector<int> result;
for (int i = start; i < end; i++) {
result.push_back(i);
}
return result; // NRVO: usually built in the caller's memory; otherwise moved, never copied
}
int main() {
auto v = makeRange(0, 1000); // efficient — no extra copy
}
There are two related rules here. Returning a temporary (return std::vector<int>(10);) is guaranteed copy elision since C++17: the object is created directly in the caller’s variable. Returning a named local like result is “named return value optimization” (NRVO), which all mainstream compilers perform in simple cases like this but the standard does not require. When NRVO does not apply — for example, a function that returns one of two different local variables depending on a condition — the local is treated as an rvalue and moved, which for a vector is a few pointer copies. So returning by value is cheap either way. The one thing not to do is return std::move(result);: it prevents NRVO, and compilers warn about it (-Wpessimizing-move).
Never Return References to Locals
A local variable is destroyed when the function returns. Returning a reference to it is undefined behavior:
// WRONG — dangling reference
int& getLocal() {
int x = 42;
return x; // x is destroyed here — reference is dangling
}
// CORRECT — return by value
int getLocal() {
int x = 42;
return x; // copy returned, x destroyed — caller gets the copy
}
The dangling version is dangerous precisely because it often seems to work: the stack memory where x lived still contains 42 right after the call, so a quick test prints the expected value. The next function call reuses that stack space and the “value” changes underneath you. Compilers warn (reference to local variable 'x' returned [-Wreturn-local-addr]), and AddressSanitizer with detect_stack_use_after_return catches it at runtime. Returning a reference is fine when the referenced object outlives the call — a member of *this, an element of a container passed in by reference, or a static.
void Functions
Functions that perform actions but produce no value use void:
void printDivider(char ch, int count) {
for (int i = 0; i < count; i++) {
std::cout << ch;
}
std::cout << '\n';
}
// void functions use return; with no value to exit early
void processIfValid(int value) {
if (value < 0) return; // early exit
// ...process...
}
Default Arguments
Parameters can have default values when not provided:
void printBox(int width, int height = 5, char fill = '*') {
for (int row = 0; row < height; row++) {
for (int col = 0; col < width; col++) {
std::cout << fill;
}
std::cout << '\n';
}
}
int main() {
printBox(10); // width=10, height=5, fill='*'
printBox(8, 3); // width=8, height=3, fill='*'
printBox(6, 4, '#'); // width=6, height=4, fill='#'
}
Rules:
- Defaults apply right-to-left — all parameters to the right of a default must also have defaults
- Put defaults in the declaration (header), not in the definition
// Header — declaration with defaults
void connect(const std::string& host, int port = 8080, bool secure = false);
// Source — definition without defaults (they're already in the header)
void connect(const std::string& host, int port, bool secure) {
// ...
}
Default arguments are substituted at the call site, at compile time. That has consequences beyond where to write them. Changing a default in a header does not affect already-compiled code that called the function until it is recompiled, which matters for shared libraries. With virtual functions, the default comes from the static type of the expression used to call it, not from the overriding function, so Base* p = new Derived; p->f(); uses Base’s default even though Derived::f runs — a known trap, which is why guidelines say not to change defaults in overrides. And long lists of defaulted bool parameters make call sites unreadable (connect("db", 8080, true) — true what?); an options struct or an enum usually reads better.
Function Overloading
Multiple functions with the same name but different parameter types:
// Three versions of print — different parameter types
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 string&)
}
You cannot overload on return type alone — the parameter list must differ:
int getValue(); // OK
double getValue(); // compile error — same name and parameters
The compiler chooses an overload by ranking how well each argument matches: exact match first, then promotions (float to double, char to int), then standard conversions (int to double, pointer to bool), then user-defined conversions (const char* to std::string). That ranking explains the results above — print("hello") reaches the std::string overload only because no better match exists. It also explains two surprises. Adding void print(bool) would silently capture print("hello"), because pointer-to-bool is a standard conversion and beats the user-defined conversion to std::string. And print(42L) (a long) fails with call of overloaded 'print(long int)' is ambiguous, since long to int and long to double are both standard conversions of the same rank. When overloads get hard to reason about, distinct names (printInt, printText) or a single template are often clearer.
Recursion
A function that calls itself. Classic examples:
// Factorial: n! = n * (n-1) * ... * 1
long long factorial(int n) {
if (n <= 1) return 1; // base case
return n * factorial(n - 1); // recursive case
}
// Fibonacci — note: O(2^n) time, impractical for large n
int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
// Better: iterative Fibonacci — O(n) time
long long fibIterative(int n) {
if (n <= 1) return n;
long long a = 0, b = 1;
for (int i = 2; i <= n; i++) {
long long c = a + b;
a = b;
b = c;
}
return b;
}
Every recursive function needs a base case that stops the recursion. Without it, the program runs until the stack overflows.
Each recursive call consumes a stack frame, and the default stack is only around 1 MB on Windows and 8 MB for the main thread on typical Linux systems, so even correct recursion can crash with a stack overflow (a segmentation fault on Linux) when the depth reaches tens or hundreds of thousands — recursing over a long linked list or a degenerate tree is the usual way to find out. The naive fib has a different problem: it recomputes the same values exponentially many times, so fib(45) already takes seconds; the iterative version or memoization fixes that. And factorial overflows quietly: long long holds up to 20!, and factorial(21) is signed overflow, which is undefined behavior rather than an error. For a beginner, the practical rule is to use recursion where the data is naturally recursive and shallow (trees, divide-and-conquer) and loops for linear sequences.
Declaration order, missing returns, and dangling references
Calling before declaring:
int main() {
result = add(2, 3); // error: add not declared yet
}
int add(int a, int b) { return a + b; }
// Fix: declare add before main, or define add before main
Missing return on some path:
int sign(int x) {
if (x > 0) return 1;
if (x < 0) return -1;
// compiler may warn: control reaches end without return
// Fix: add return 0;
}
Passing large types by value:
double sumAll(std::vector<int> v) { ... } // copies the entire vector
double sumAll(const std::vector<int>& v) { ... } // no copy — correct
Returning reference to local:
const std::string& getName() {
std::string name = "Alice"; // local variable
return name; // UB: name destroyed at end of function
}
Putting it together: small string utilities
#include <string>
#include <algorithm>
#include <cctype>
#include <iostream>
#include <sstream>
#include <vector>
// Count occurrences of a character
int countChar(const std::string& s, char target) {
int count = 0;
for (char c : s) {
if (c == target) count++;
}
return count;
}
// Check if string is a palindrome
bool isPalindrome(const std::string& s) {
std::string lower = s;
std::transform(lower.begin(), lower.end(), lower.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
std::string reversed = lower;
std::reverse(reversed.begin(), reversed.end());
return lower == reversed;
}
// Split string by delimiter
std::vector<std::string> split(const std::string& s, char delimiter) {
std::vector<std::string> result;
std::stringstream ss(s);
std::string token;
while (std::getline(ss, token, delimiter)) {
result.push_back(token);
}
return result;
}
int main() {
std::cout << countChar("mississippi", 's') << '\n'; // 4
std::cout << isPalindrome("racecar") << '\n'; // 1 (true)
std::cout << isPalindrome("hello") << '\n'; // 0 (false)
auto words = split("one,two,three", ',');
for (const auto& w : words) {
std::cout << w << '\n'; // one / two / three
}
}
These utilities apply the parameter rules from earlier: every function takes its input string by const& because it only reads it, and each returns its result by value, including split, whose vector is returned without copying. The lambda passed to std::transform converts each character to unsigned char before calling std::tolower, because passing a negative char — any non-ASCII byte on platforms where char is signed — to the C character functions is undefined behavior; the older ::tolower shortcut you often see skips that step and also relies on the global-namespace version being declared. Note that split drops a trailing empty field ("a,b," gives two tokens), which may or may not be what your data format expects; that kind of edge case is exactly what a small unit test around each function should pin down. With C++17, countChar and isPalindrome could take std::string_view instead, so that calling them with a string literal does not allocate a temporary std::string.
Parameter and return rules to start with
- Pass small types by value, large types by const reference — this is the most common function parameter pattern
- Const reference (
const T&) gives efficient read-only access without copying - Return by value for most things — RVO eliminates the copy overhead
- Never return a reference to a local variable — it’s undefined behavior (dangling reference)
- Overloading lets you use the same name for functions that do the same thing with different types
- Default arguments go in the declaration (header), not the definition
- Recursion needs a base case — always think about when it stops
Frequently Asked Questions (FAQ)
Q. Should default arguments go on the declaration or the definition?
A. Put them on the declaration that callers see, normally the one in the header, and do not repeat them on the definition. The compiler fills in default arguments at the call site, so a caller only gets a default it can see in its declaration, and giving the same default again in the same scope is a compile error. Defaults must also come last: once one parameter has a default, every parameter after it needs one too.
Related Articles
- C++ if and switch Pitfalls
- C++ Classes and Objects: Constructors, Access Control, and
- C++ Loops Masterclass