Functions in C++
As programs grow beyond a few dozen lines, writing everything inside main becomes unmanageable. You repeat logic, fix bugs in multiple places, and struggle to understand what any given section does. Functions solve this by letting you name a reusable block of code, give it inputs, and have it produce an output.
Functions are the primary unit of code organisation in C++. Every senior engineer at companies like Amazon Hyderabad or Swiggy Bengaluru will tell you the same thing: well-designed functions — small, well-named, with a single clear responsibility — are the hallmark of professional-quality code. This chapter covers everything from the basics to recursion and scope rules.
Defining and Calling Functions
A function definition has four parts:
return_type function_name(parameter_list) {
// function body
return value; // if return_type is not void
}
Example — a function that computes the square of a number:
#include <iostream>
int square(int n) {
return n * n;
}
int main() {
int result = square(7);
std::cout << "Square of 7: " << result << "\n"; // 49
// Functions can be called multiple times with different arguments
for (int i = 1; i <= 5; i++) {
std::cout << i << "^2 = " << square(i) << "\n";
}
return 0;
}
Terminology:
- Parameter — the variable listed in the function definition (
int nabove). - Argument — the actual value passed when the function is called (
7above). - Return value — the value the function sends back to the caller.
void Functions
A function that performs an action but does not return a value has return type void:
void printDivider(int width) {
for (int i = 0; i < width; i++) {
std::cout << '-';
}
std::cout << '\n';
}
You can call return; (without a value) to exit a void function early.
Function Declarations (Prototypes)
In C++, a function must be declared before it is called. One way is to define the function above main. Another is to write a forward declaration (prototype) at the top and define the function body later:
#include <iostream>
// Forward declaration
double hypotenuse(double a, double b);
int main() {
std::cout << hypotenuse(3.0, 4.0) << "\n"; // 5
return 0;
}
// Full definition can appear after main
double hypotenuse(double a, double b) {
return std::sqrt(a * a + b * b);
}
Prototypes are essential in large projects where function definitions span multiple source files.
Pass by Value vs Pass by Reference vs Pass by Pointer
How arguments are passed to functions determines whether the function can modify the caller's variables and how much data is copied.
Pass by Value (Default)
A copy of the argument is made. Changes inside the function do not affect the original:
void doubleIt(int x) {
x = x * 2; // modifies the local copy only
}
int main() {
int n = 5;
doubleIt(n);
std::cout << n << "\n"; // still 5
return 0;
}
Pass by value is safe (the caller's data is protected) but makes a copy — for large objects like big vectors or strings, this copy can be expensive.
Pass by Reference
A reference is an alias for the caller's variable. The function works directly on the original — no copy is made:
void doubleIt(int& x) { // note the & — this is a reference parameter
x = x * 2;
}
int main() {
int n = 5;
doubleIt(n);
std::cout << n << "\n"; // now 10 — the original was modified
return 0;
}
References are also used for output parameters — returning multiple values without a struct:
void minMax(const std::vector<int>& v, int& minVal, int& maxVal) {
minVal = v[0];
maxVal = v[0];
for (int x : v) {
if (x < minVal) minVal = x;
if (x > maxVal) maxVal = x;
}
}
When you want to pass a large object efficiently but do not want the function to modify it, use const reference:
double average(const std::vector<int>& marks) {
double sum = 0;
for (int m : marks) sum += m;
return sum / marks.size();
}
This avoids copying the vector while preventing accidental modification.
Pass by Pointer
A pointer holds the memory address of a variable. Passing a pointer lets the function modify the original by dereferencing the address:
void doubleIt(int* ptr) {
*ptr = (*ptr) * 2; // dereference pointer to access and modify the value
}
int main() {
int n = 5;
doubleIt(&n); // pass the address of n
std::cout << n << "\n"; // 10
return 0;
}
Comparison
| Pass by Value | Pass by Reference | Pass by Pointer | |
|---|---|---|---|
| Copy made? | Yes | No | No (pointer is copied, not the object) |
| Caller's data modifiable? | No | Yes | Yes |
Can be nullptr? | N/A | No | Yes |
| Syntax at call site | f(n) | f(n) | f(&n) |
| Modern C++ preference | For small types | Preferred over pointers | For optional/nullable args |
In modern C++, prefer references over pointers for parameters — they are cleaner and cannot be nullptr. Use pointers when you need to represent "no value" (nullable) or when working with legacy C APIs.
Default Arguments
Parameters can have default values, used when the caller does not supply that argument:
double compoundInterest(double principal, double rate, int years = 1) {
double amount = principal;
for (int i = 0; i < years; i++) {
amount *= (1.0 + rate / 100.0);
}
return amount - principal;
}
int main() {
// Invest ₹1,00,000 at 8% for different periods
std::cout << "1-year interest: " << compoundInterest(100000, 8) << "\n";
std::cout << "5-year interest: " << compoundInterest(100000, 8, 5) << "\n";
std::cout << "10-year interest: " << compoundInterest(100000, 8, 10) << "\n";
return 0;
}
Rules for default arguments:
- They must appear at the right end of the parameter list — you cannot have a default argument followed by a non-default one.
- If a default is given in a prototype, do not repeat it in the definition (and vice versa — pick one location).
Function Overloading
C++ allows multiple functions to share the same name, provided their parameter lists differ (in number, type, or order of parameters). The compiler chooses the correct version based on the arguments at the call site. This is called function overloading.
#include <iostream>
// Overloaded area() functions
double area(double radius) {
return 3.14159265 * radius * radius;
}
double area(double length, double width) {
return length * width;
}
int area(int side) { // integer version for square
return side * side;
}
int main() {
std::cout << "Circle area: " << area(5.0) << "\n"; // calls first
std::cout << "Rectangle area: " << area(4.0, 6.0) << "\n"; // calls second
std::cout << "Square area: " << area(7) << "\n"; // calls third
return 0;
}
Note: The return type alone is not sufficient to distinguish overloads. int f() and double f() cannot coexist because the compiler resolves overloads from the argument list, not the expected return type.
Overloading is the foundation of operator overloading (used extensively in the STL) and is one of the pillars of polymorphism in C++.
Inline Functions
Calling a function has a small overhead: saving registers, jumping to the function's address, setting up a stack frame, and returning. For very small, frequently called functions, this overhead can be noticeable in tight loops.
The inline keyword is a hint to the compiler to expand the function body at the call site (like a macro, but type-safe):
inline int clamp(int value, int lo, int hi) {
if (value < lo) return lo;
if (value > hi) return hi;
return value;
}
Modern compilers (g++ -O2) inline small functions automatically, so inline is mainly a documentation signal today. Never use inline on large functions — it increases binary size and can hurt instruction-cache performance.
Recursion
A function that calls itself is recursive. Recursion elegantly expresses problems that have a self-similar structure.
Every recursive function needs:
- A base case — a condition under which the function returns directly without calling itself (prevents infinite recursion).
- A recursive case — the function calls itself on a smaller subproblem.
Classic Example 1: Factorial
The factorial of n (written n!) is defined as n × (n-1) × ... × 1, with 0! = 1.
#include <iostream>
long long factorial(int n) {
if (n <= 0) return 1; // base case
return n * factorial(n - 1); // recursive case
}
int main() {
for (int i = 0; i <= 10; i++) {
std::cout << i << "! = " << factorial(i) << "\n";
}
return 0;
}
Classic Example 2: Fibonacci Numbers
The Fibonacci sequence: 0, 1, 1, 2, 3, 5, 8, 13, ... where each term equals the sum of the two before it.
int fibonacci(int n) {
if (n <= 0) return 0; // base case
if (n == 1) return 1; // base case
return fibonacci(n - 1) + fibonacci(n - 2); // recursive case
}
Warning: This naive implementation has exponential time complexity — O(2^n). Computing fibonacci(50) would take longer than the age of the universe. The fix is memoisation (caching results) or an iterative approach — both are important competitive programming techniques covered in later chapters.
Classic Example 3: Tower of Hanoi
The Tower of Hanoi puzzle: move n discs from peg A to peg C, using peg B as auxiliary, following the rule that a larger disc may never rest on a smaller one.
The recursive insight: to move n discs from A to C, (a) move n-1 discs from A to B, (b) move the largest disc from A to C, (c) move n-1 discs from B to C.
#include <iostream>
void hanoi(int n, char from, char to, char aux) {
if (n == 1) {
std::cout << "Move disc 1 from " << from << " to " << to << "\n";
return; // base case
}
hanoi(n - 1, from, aux, to); // move n-1 to aux
std::cout << "Move disc " << n << " from " << from << " to " << to << "\n";
hanoi(n - 1, aux, to, from); // move n-1 to destination
}
int main() {
int discs = 3;
std::cout << "Tower of Hanoi with " << discs << " discs:\n";
hanoi(discs, 'A', 'C', 'B');
return 0;
}
Output for 3 discs:
Move disc 1 from A to C
Move disc 2 from A to B
Move disc 1 from C to B
Move disc 3 from A to C
Move disc 1 from B to A
Move disc 2 from B to C
Move disc 1 from A to C
The minimum number of moves for n discs is 2^n - 1. For n = 64 discs this would require roughly 18 quintillion moves — the legend says the world will end when the monks of a mythical temple complete the 64-disc puzzle.
Scope and Lifetime of Variables
Local Variables
Declared inside a function or block. They exist only within that block — they are created when execution enters the block and destroyed when it exits:
void foo() {
int x = 10; // local to foo — created here
{
int y = 20; // local to this inner block
// both x and y are accessible here
}
// y no longer exists here — only x remains
} // x destroyed here
Local variables have automatic storage duration — the runtime manages their memory on the stack automatically.
Global Variables
Declared outside all functions, at file scope. They exist for the entire lifetime of the program:
#include <iostream>
int globalCounter = 0; // global variable
void increment() {
globalCounter++;
}
int main() {
increment();
increment();
std::cout << globalCounter << "\n"; // 2
return 0;
}
Global variables are accessible from any function in the file. Use them sparingly — they make code harder to reason about and test. Most professional codebases avoid global mutable state.
Static Local Variables
A local variable declared with static has static storage duration — it is initialised once and retains its value between function calls:
#include <iostream>
void countCalls() {
static int count = 0; // initialised only on the first call
count++;
std::cout << "Function called " << count << " time(s)\n";
}
int main() {
countCalls(); // Function called 1 time(s)
countCalls(); // Function called 2 time(s)
countCalls(); // Function called 3 time(s)
return 0;
}
Static locals are great for caches, counters, and singleton-like resources inside a function. Unlike global variables, they are only accessible within their enclosing function — good encapsulation.
Scope Summary
| Variable Kind | Declared In | Accessible From | Lifetime |
|---|---|---|---|
| Local | Function/block body | That block only | Until block exits |
| Global | File scope (outside functions) | Entire file (and other files with extern) | Entire program |
| Static local | Function body, with static | That function only | Entire program (initialised once) |
| Parameter | Function parameter list | Function body | Until function returns |
Common Pitfalls
Missing base case in recursion. A recursive function without a reachable base case calls itself infinitely until the call stack overflows — a "stack overflow" crash. Always trace the recursion to confirm the base case is reachable.
Pass by value when you mean pass by reference. If a function is supposed to modify its argument but uses pass by value, the modification is silently discarded. Prefix your mental review: "does this function need to change the caller's data?"
Returning a reference to a local variable. A local variable is destroyed when the function returns. Returning a reference (or pointer) to it produces a dangling reference — undefined behaviour that often causes crashes in seemingly unrelated code.
int& dangerous() {
int local = 42;
return local; // WRONG: local is destroyed after return
}
Ambiguous overloads. If you call f(5) and there is both f(int) and f(double), the call is unambiguous (int preferred). But f(5.0f) with f(int) and f(double) is ambiguous (float can be converted to either). The compiler will refuse to compile — resolve by casting.
Shadowing. A local variable with the same name as a global variable hides (shadows) the global within the function. Enable -Wshadow to detect this.
Overuse of global variables. Globals make it impossible to reason about a function in isolation — the function's behaviour depends on invisible external state. Prefer passing data as parameters and returning results.
Practice Exercises
-
Write an overloaded function
maxthat works forint,double, andlong long. Call all three versions frommain. -
Write a recursive function
power(base, exponent)that computesbaseraised to a non-negative integerexponent. Then write an iterative version and compare both forpower(2, 30). -
Write a function
isPalindrome(const std::string& s)that returnstrueif the string reads the same forwards and backwards. Test with "madam", "level", "Racecar" (case-sensitive), and "hello". -
Implement a function
gcd(int a, int b)using the Euclidean algorithm recursively:gcd(a, b) = gcd(b, a % b), with base casegcd(a, 0) = a. Then use it to computelcm(a, b) = (a / gcd(a, b)) * b. -
Write a function with a
staticlocal variable that generates unique IDs starting from 1 — each call togenerateID()returns the next integer. -
Write a
swap(int& a, int& b)function using pass-by-reference. Verify that after calling it, the values inmainare exchanged. Then write a second version using pointers and compare the call site syntax.
Summary
- A function encapsulates a reusable block of code with a name, parameter list, and return type.
- Pass by value copies the argument — the original is protected but copying is expensive for large objects.
- Pass by reference (
&) avoids copying and allows modification of the original; useconst&to pass efficiently without allowing modification. - Pass by pointer (
*) is similar to reference but allowsnullptr— prefer references in modern C++. - Default arguments reduce boilerplate for commonly used parameter values; they must appear at the right end of the parameter list.
- Function overloading lets multiple functions share a name, differing in parameter count or types.
inlinehints at expanding functions at the call site; modern compilers do this automatically at-O2.- Recursion expresses self-similar problems elegantly; always define a base case to prevent infinite recursion.
- Factorial, Fibonacci, and Tower of Hanoi are the three classic recursion examples encountered in FAANG interviews.
- Local variables live only within their enclosing block; global variables live for the program's lifetime; static locals live for the program's lifetime but are scoped to their function.
- Never return a reference or pointer to a local variable — the variable is destroyed when the function exits.