A Function Is a Contract
The everyday description of a function is "reusable code", and that undersells it. The more useful way to think about a function is as a contract: its signature states exactly what it needs and exactly what it gives back, and everything else about it — how it works, what temporary variables it uses — is nobody else's concern.
In C++ that contract is enforced by the compiler, because both the parameter types and the return type are written down. double average(int a, int b) promises that if you hand it two integers, you get a double back. Call it with a string and the program will not build. This is the main reason large C++ codebases stay navigable: you can read one function's signature and know how to use it without reading its body.
The practical advice that follows is about size and naming. A function should do one identifiable thing and its name should say what that is. calculateGst is a good name; process is not. If you find yourself writing a comment inside a function that says "now validate the input", that block usually wants to be its own function called validateInput — at which point the comment is redundant and the code became testable.
One more thing the compiler gives you for free: a function that must return a value and can reach its end without doing so is a bug that -Wall catches. A function whose declared return type is not void and that falls off the end without a return is undefined behaviour, and the resulting garbage value is very hard to trace back to its cause.
#include <iostream>
#include <string>
// The signature is the contract: two ints in, one double out.
double average(int a, int b) {
return (a + b) / 2.0;
}
// void: does work, returns nothing
void greet(const std::string& name) {
std::cout << "Hello, " << name << "!\n";
}
// One job, named after that job
bool isValidMarks(int m) {
return m >= 0 && m <= 100;
}
int main() {
std::cout << average(7, 10) << '\n'; // 8.5
greet("Ananya");
std::cout << isValidMarks(105) << '\n'; // 0
} voidmeans "returns nothing", not "returns zero". You may still write a barereturn;inside avoidfunction to leave it early, which is often clearer than wrapping the rest of the body in anif.
By Value, By Reference, By const Reference
How you declare a parameter decides whether the function gets a copy or the original, and it is the most consequential small decision in everyday C++. There are three forms and each says something different to both the compiler and the reader.
By value — void f(int x) — gives the function its own copy. Changes to x do not touch the caller's variable. For small types like int, double and char, this is the right default: copying eight bytes is free, and the function cannot accidentally modify something it should not.
By reference — void f(int& x) — gives the function an alias for the caller's variable. Assigning to x changes the original. Use it when modifying the caller's object is the whole point of the call: swapping two values, filling a vector the caller supplied, updating a running total.
By const reference — void f(const std::string& s) — gives an alias but forbids modification. This is the form to use for any object that is expensive to copy but that you only need to read. Passing a std::string or a std::vector by value copies its entire contents, including a heap allocation; passing by const reference copies nothing at all. On a vector of ten thousand elements, inside a loop, this is not a micro-optimisation — it is the difference between a program that finishes and one that does not.
The const is doing two jobs, and the second is arguably more valuable than the performance. It documents in the signature that the function will not change your object, so a reader of the calling code does not have to go and check. And it lets you pass temporaries and literals — a non-const reference cannot bind to the result of an expression, which is why f(name + " Sharma") compiles for const std::string& and fails for std::string&.
#include <iostream>
#include <string>
#include <vector>
void byValue(int x) { x *= 2; } // copy — caller unaffected
void byReference(int& x) { x *= 2; } // alias — caller's value changes
void readOnly(const std::vector<int>& v) { // no copy, cannot modify
std::cout << v.size() << " marks\n";
}
// A classic use of references: modify both arguments
void swapMarks(int& a, int& b) {
int temp = a;
a = b;
b = temp;
}
int main() {
int n = 5;
byValue(n);
std::cout << n << '\n'; // 5 — unchanged
byReference(n);
std::cout << n << '\n'; // 10 — changed
int p = 1, q = 2;
swapMarks(p, q);
std::cout << p << ' ' << q << '\n'; // 2 1
std::vector<int> marks(10000, 75);
readOnly(marks); // nothing copied
// Only the const& form accepts a temporary
std::string first = "Ananya";
// void bad(std::string& s); bad(first + " Sharma"); // would not compile
} int,double,char,bool, pointers — pass by valuestd::string,std::vector, any class you wrote, when you only read it — pass byconstreference- Anything you intend to modify for the caller — pass by non-const reference
- A parameter the function will keep its own copy of and then modify — pass by value and modify the copy
std::swapfrom<utility>already does whatswapMarksdoes, for any type. Write your own once to understand references, then use the standard one.
Returning Values, and the Dangling Reference Trap
Returning by value is the normal thing to do and it is not as expensive as it looks. Compilers construct the returned object directly in the caller's storage instead of building it and then copying it — an optimisation called copy elision, which since C++17 is guaranteed in the common cases. So std::vector<int> buildList() does not copy the vector on the way out, and you should write it without hesitation.
What you must never do is return a reference or a pointer to a local variable. Local variables live in the function's stack frame, and that frame is discarded the moment the function returns. The reference you handed back now refers to memory that has been released and will shortly be reused by the next function call. Reading through it is undefined behaviour.
The reason this specific bug is so dangerous is its symptom. The memory has not been wiped — it still contains the old bytes for a short while — so the value is often correct the first time you read it and wrong later, or correct in a small test program and wrong in the real one. Students conclude the bug is somewhere else entirely. Compilers do warn about the obvious cases (warning: reference to local variable returned), which is one more argument for never ignoring warnings.
Returning a reference is legitimate when the thing referred to outlives the call: a reference to an element of a container the caller owns, or to a member of the object the method was called on — that is exactly how std::vector::operator[] works. The test to apply is simple: after this function returns, does the object still exist? If it was declared inside the function, the answer is no.
#include <iostream>
#include <string>
#include <vector>
// CORRECT: return by value. The compiler elides the copy.
std::vector<int> firstNSquares(int n) {
std::vector<int> result;
result.reserve(n);
for (int i = 1; i <= n; ++i) result.push_back(i * i);
return result;
}
// BROKEN: `local` is destroyed the moment this returns
// std::string& brokenName() {
// std::string local = "Ananya";
// return local; // dangling reference
// }
// FINE: the referred-to object outlives the call
class Roster {
std::vector<std::string> names_;
public:
void add(const std::string& n) { names_.push_back(n); }
const std::string& at(std::size_t i) const { return names_[i]; }
};
int main() {
std::vector<int> sq = firstNSquares(5);
for (int x : sq) std::cout << x << ' '; // 1 4 9 16 25
std::cout << '\n';
Roster r;
r.add("Ananya");
std::cout << r.at(0) << '\n'; // safe: names_ still exists
} - The same rule applies to pointers: never
return &local;. And it applies to lambdas that capture by reference — if the lambda outlives the variables it captured, every one of those captures is dangling.
Default Arguments and Overloading
A default argument lets a parameter be omitted at the call site. double gst(double amount, double rate = 0.18) can be called with one argument or two. It is the right tool when a function has a sensible common case and a rarely-used variation, and it saves you writing two near-identical functions.
Two rules govern them. Defaults must be the trailing parameters — you cannot default the first and require the second, because the compiler matches arguments positionally and would have no way to tell which one you supplied. And a default must be written exactly once across all declarations of the function. In practice that means putting it in the header declaration and not repeating it in the definition in the .cpp file, which is a small rule that trips up everyone once.
Overloading is having several functions with the same name distinguished by their parameter lists. The compiler picks the one whose parameters best match the arguments you supplied. This is why std::max works on int, double and std::string without you thinking about it, and why std::cout << can print every type — << is overloaded many times over.
The rule people forget is that the return type is not part of the decision. int f(int) and double f(int) cannot coexist, because at a call like f(3) the compiler would have no way to choose. Overloads must differ in their parameters.
Defaults and overloads interact badly, and the resulting error message is unhelpful. If you declare both void log(const std::string&) and void log(const std::string&, int level = 1), then log("hi") matches both equally well and the compiler reports an ambiguous call. When you find yourself adding a default argument to a function that is already overloaded, stop and pick one mechanism or the other.
#include <iostream>
#include <string>
// Defaults must be the trailing parameters
double gst(double amount, double rate = 0.18) {
return amount * rate;
}
// Overloads differ in their PARAMETERS, never only in return type
int largest(int a, int b) { return (a > b) ? a : b; }
double largest(double a, double b) { return (a > b) ? a : b; }
std::string largest(const std::string& a, const std::string& b) {
return (a > b) ? a : b;
}
// int ambiguous(int);
// double ambiguous(int); // ERROR: differs only by return type
int main() {
std::cout << gst(1000) << '\n'; // 180 — default rate used
std::cout << gst(1000, 0.05) << '\n'; // 50
std::cout << largest(10, 20) << '\n'; // 20 (int version)
std::cout << largest(3.14, 2.71) << '\n'; // 3.14 (double version)
std::cout << largest(std::string("apple"),
std::string("banana")) << '\n'; // banana
} - In a header/source split, write
double gst(double amount, double rate = 0.18);in the.handdouble gst(double amount, double rate) { ... }in the.cpp. Repeating the= 0.18in the definition is an error, and the message about redefinition of a default argument is easy to misread.
Lambdas: Functions Written Where They Are Used
Sometimes you need a tiny function for exactly one call — a comparison rule for a sort, a test for a filter — and giving it a name and a place at the top of the file makes the code harder to follow rather than easier. A lambda is an unnamed function you write inline, introduced in C++11 and now everywhere in modern C++.
The syntax is [capture](parameters) { body }. The parameters and body work exactly like a normal function; the return type is deduced, though you can state it with -> type when you need to. The interesting part is the square brackets at the front, the capture list, which says which variables from the surrounding scope the lambda is allowed to see. An empty [] means it uses nothing from outside.
There are two ways to capture. [x] copies x into the lambda, so the lambda has its own snapshot taken at the moment it was created. [&x] captures by reference, so the lambda sees the live variable and can modify it. The shorthands [=] and [&] capture everything used, by copy and by reference respectively.
The gotcha is the same one as returning a reference. A lambda that captures by reference and then outlives the captured variables — because you stored it, or returned it, or passed it to something that runs later — is holding dangling references. If a lambda is used immediately, as in a call to std::sort, capturing by reference is safe and cheap. If it is stored for later, capture by value.
One small detail worth knowing: a lambda's copies of captured variables are const by default, so you cannot modify them inside the body. Adding the mutable keyword after the parameter list lifts that restriction, and it modifies the lambda's own copy, not the original.
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<int> marks = {72, 65, 91, 48, 88};
// Sort descending using an inline comparison rule
std::sort(marks.begin(), marks.end(),
[](int a, int b) { return a > b; });
// Capture by value: the lambda gets its own snapshot of `pass`
int pass = 50;
int passed = static_cast<int>(
std::count_if(marks.begin(), marks.end(),
[pass](int m) { return m >= pass; }));
std::cout << passed << " passed\n"; // 4
// Capture by reference: modify a variable in the enclosing scope
int total = 0;
std::for_each(marks.begin(), marks.end(),
[&total](int m) { total += m; });
std::cout << "total " << total << '\n'; // 364
// Storing a lambda in a named variable
auto grade = [](int m) -> std::string {
if (m >= 75) return "Distinction";
if (m >= 33) return "Pass";
return "Fail";
};
std::cout << grade(91) << '\n'; // Distinction
} - If you store a lambda, prefer
autofor its type — every lambda has its own unique unnameable type.std::function<int(int)>from<functional>can also hold one and is useful when you need a common type for several different lambdas, at the cost of a little run-time overhead.
Recursion, and Where It Stops
A recursive function is one that calls itself on a smaller version of the same problem. It is the natural way to express anything with a nested structure — a tree, a directory listing, a divide-and-conquer sort — and it appears constantly in placement interviews, so it is worth being fluent in.
Every recursive function needs two things. A base case that returns an answer without recursing, and a recursive case that makes the problem strictly smaller before calling itself. If the recursive case does not move towards the base case on every call, you have written an infinite recursion, and the failure mode is a crash rather than a hang: each call consumes a stack frame, and when the stack runs out the program dies with a stack overflow.
That gives you the practical limit. Stack frames are not free, and the usable depth on a typical desktop runs to some tens of thousands of calls, not millions. Recursion over a balanced tree is completely safe, because depth grows with the logarithm of the size. Recursion that walks a list of a million elements one at a time is not, and should be rewritten as a loop.
The other cost to know about is repeated work. The textbook recursive Fibonacci recomputes the same values over and over, and its running time grows exponentially — fib(45) written that way takes an uncomfortably long time. The fix is memoisation: keep a table of answers you have already computed and check it before recursing. That one change takes the same function from exponential to linear, and recognising when it applies is most of what dynamic programming is.
#include <iostream>
#include <vector>
long long factorial(int n) {
if (n <= 1) return 1; // base case
return n * factorial(n - 1); // strictly smaller
}
// Exponential: recomputes the same values endlessly
long long slowFib(int n) {
if (n < 2) return n;
return slowFib(n - 1) + slowFib(n - 2);
}
// Memoised: each value computed once
long long fib(int n, std::vector<long long>& memo) {
if (n < 2) return n;
if (memo[n] != -1) return memo[n]; // already known
memo[n] = fib(n - 1, memo) + fib(n - 2, memo);
return memo[n];
}
int main() {
std::cout << factorial(10) << '\n'; // 3628800
std::vector<long long> memo(51, -1);
std::cout << fib(50, memo) << '\n'; // 12586269025, instantly
std::cout << slowFib(25) << '\n'; // 75025, noticeably slower
} factorialreturnslong longfor a reason: 21! already overflows a 64-bit integer, and 13! overflows a 32-bitint. Recursive maths functions overflow much sooner than students expect, and the wrong answer arrives silently.
