C++ Programming
Lesson 06 of 24

Lesson 06 · 24 lesson course

6. Calling and Returning with Functions

Appropriate use of a function

Programming glossary 50 terms mentioned in this lesson

Select any term for a clear definition. The return button brings you back to the exact term link you used.

Abstract nested function chambers linked by outward call paths and returning result paths.

 

 

Lesson: Calling Functions & Parameter Lists (C++)

Calling a function correctly means constructing the parameter list so it exactly matches the function’s required types. In C++, a function’s identity is not just its name: the parameter types are part of its signature. If the call’s parameter list doesn’t match, compilation fails (or calls a different overload).


1) Function Calls & Exact Type Matching

Declaration, then call:

int f1(int x) {
    int y = 5;
    return y;      // demo only: ignores x
}

int main() {
    int z = f1(2); // OK: argument type int matches parameter type int
}

The compiler enforces type/size compatibility so that the value you pass fits in the memory reserved for the parameter. This protects memory layout and prevents corruption; any logic safety you gain is a happy by-product.

Programming Insight (AI) — Check Call Signatures
  • Paste a function and its call sites; ask AI to flag mismatched types and suggest minimal, safe fixes.
  • Have AI propose overloads/templates when multiple type variants are needed.
  • Ask AI to generate usage examples for each overload to prevent accidental conversions.

2) Substitution & Nested Calls

The value for each parameter must be computed before the call can proceed. Parenthesised, nested calls are evaluated “inside-out” for each nested expression:

int f1(int x) { return 5; }

int main() {
    int z = f1( f1(2) ); // inner f1(2) is evaluated to produce the argument of the outer call
}

This enables concise code, but readability suffers when nesting is overused:

int f1(int){ return 5; }
int f2(int){ return 7; }

int z = f1( f2( f1(1) ) ); // hard to read
// Clearer:
int a = f1(1);
int b = f2(a);
int z2 = f1(b);
Programming Insight (AI) — De-nest for Clarity
  • Ask AI to rewrite deeply nested calls into named intermediate steps.
  • Have AI extract a pipeline function that documents the order of operations.

3) Order of Evaluation Pitfalls (Multiple Arguments)

When a function has multiple arguments, do not rely on the order in which those arguments are evaluated — the standard allows the compiler to choose. Side effects in separate arguments can therefore behave unexpectedly.

int i = 0;
int g(){ return i++; }
int h(){ return i++; }

// Unspecified relative evaluation order of arguments:
// either g() or h() may run first; 'i' is incremented twice, order unknown.
int r = std::max(g(), h());

// Safer:
int a = g();
int b = h();
int r2 = std::max(a, b);
Programming Insight (AI) — Spot Side-Effect Hazards
  • Ask AI to find calls where multiple arguments mutate the same state, and split them safely.
  • Have AI suggest pure helpers to separate computation from mutation.

4) Overloads, Conversions & Ambiguity

C++ allows function overloading — same name, different parameter types. The compiler selects the best match by exact match, promotions, then conversions. Ambiguities cause compile errors.

void print(int);
void print(double);
// void print(std::string_view); // uncomment to support strings

print(42);     // calls print(int)
print(3.14);   // calls print(double)
print('A');    // character promotes; likely print(int)
print("hi");   // ❌ ambiguous/missing unless string overload exists

Prefer explicit overloads (or templates) over relying on implicit conversions that can surprise callers.

Programming Insight (AI) — Safer Overload Sets
  • Have AI propose an overload or template set and generate example calls that prove resolution is unambiguous.
  • Ask AI to add explicit constructors to prevent accidental conversions at call sites.

5) Forward Declarations & Circular Calls

Mutually calling functions need at least one forward declaration so the compiler knows the second function’s signature when compiling the first. Beware unbounded mutual recursion (infinite back-and-forth).

int f2(int);          // forward declaration

int f1(int x) {       // calls f2
    int y = 5;
    return f2(y);
}

int f2(int x) {       // calls f1
    int y = 7;
    return f1(y);     // ❌ without a base case, this can recurse forever
}

Add termination conditions (base cases) or redesign to break the cycle.

Programming Insight (AI) — Prove Termination
  • Ask AI to add base cases/measures that strictly decrease to guarantee termination.
  • Have AI convert mutually recursive code into an iterative loop or a state machine if clearer.

6) Choosing Parameter Passing Style

Pick parameter types that express intent and cost:

  • By value: int f(int x) — cheap scalars; copies are fine.
  • By const reference: int f(const std::string& s) — avoid copying large objects; no modification.
  • By non-const reference: void f(std::vector<int>& v) — call intends to modify v.
  • By rvalue reference: void f(std::string&& s) — take ownership / move.
int sum(const std::vector<int>& v);     // read-only, no copy
void append(std::vector<int>& v, int x); // modifies caller's vector
void set_name(std::string&& s);          // consumes a temporary by move
Programming Insight (AI) — Pick the Right Passing Style
  • Ask AI to rewrite signatures for intent (read-only vs modify vs consume).
  • Have AI add const where safe and remove accidental copies on hot paths.

7) Worked Example — Fix the Calls

Identify problems and refactor for clarity and safety:

int f1(int){ return 5; }
int f2(int){ return 7; }

int i = 0;
int g(){ return i++; }
int h(){ return i++; }

int main() {
    int z = f1( f2( f1(1) ) );  // hard to read
    int r = std::max(g(), h()); // side-effect order risk

    // Better:
    int a = f1(1);
    int b = f2(a);
    int z2 = f1(b);

    int ga = g();
    int hb = h();
    int r2 = std::max(ga, hb);
}
Programming Insight (AI) — Automated Refactor Patch
  • Ask AI for a patch that introduces intermediates, removes ambiguous conversions, and documents intent.
  • Have AI generate unit tests ensuring the refactor preserves behaviour.

8) Quick Checklist — Safe, Readable Calls

  • Match parameter types exactly (or add explicit, safe overloads).
  • Avoid deep nesting; introduce named intermediates.
  • Do not rely on argument evaluation order; separate side effects.
  • Use forward declarations for mutual calls, but ensure base cases if recursive.
  • Choose parameter passing style to reflect intent and cost.

Mini Exercise — Call Sites, Signatures & Safe Parameter Lists

Goal: practise matching exact parameter types, de-nesting for clarity, avoiding argument order pitfalls, designing unambiguous overloads, breaking circular calls safely, and choosing the right passing style.

  1. Match the signature (no accidental conversions)
    Fix the call sites so they match the intended parameter types without surprising promotions/narrowing.
    int  scale_int(int x, int factor);
    double scale_double(double x, double factor);
    
    int main() {
        long long big = 10000000000LL;
        double d = 2.5;
    
        auto a = scale_int(42, 2.0);     // ❌ mixed types
        auto b = scale_double(3, d);     // ❌ relies on implicit int→double
        auto c = scale_int(big, 3);      // ❌ potential narrowing
    
        // TODO: Write correct calls OR adjust function signatures responsibly.
    }
    
    • Show both approaches: (1) fix the calls with explicit casts; (2) refactor signatures to accept the real use-cases (e.g., templates or wider types). State which you chose and why.
  2. De-nest for clarity (substitution still preserved)
    Rewrite the nested calls with named intermediates, then extract a tiny “pipeline” function that documents the order.
    int f1(int x){ return x + 1; }
    int f2(int x){ return x * 2; }
    int f3(int x){ return x - 3; }
    
    int main() {
        int z = f1( f2( f3( f1(5) ) ) ); // ❌ hard to read
        // TODO: z2 using intermediates; then a function int pipeline(int) { ... }
    }
    
  3. Argument evaluation order: separate side effects
    Make the result deterministic by removing side effects from argument expressions.
    #include <algorithm>
    int i = 0;
    int g(){ return i++; }
    int h(){ return i += 10; } // different side effect
    
    int main() {
        int r = std::max(g(), h()); // ❌ relative evaluation order unspecified
        // TODO: refactor to two statements (no side effects in the call).
    }
    
    • Explain (one sentence) why the standard allows either argument to be evaluated first.
  4. Overloads & ambiguity: make intent obvious
    Remove ambiguity without “mystery” conversions; prefer explicit overloads.
    #include <string>
    #include <string_view>
    
    void print(int);
    void print(double);
    // void print(std::string_view); // maybe needed?
    
    int main() {
        print(42);        // ok
        print(3.14);      // ok
        print('A');       // likely calls print(int) via promotion
        print("hi");      // ❌ ambiguous/missing
        // TODO: Add the right overload(s) and show unambiguous calls, including u8/u16 string literals if you wish.
    }
    
  5. Forward declarations & termination
    Provide the minimum forward declaration and add a base case to ensure the mutual recursion stops.
    int f2(int);                 // forward declaration
    
    int f1(int x) {              // calls f2
        if (x <= 0) return 0;    // TODO: sensible base case
        return f2(x - 1);
    }
    
    int f2(int x) {              // calls f1
        // TODO: matching base case / measure that decreases
        return f1(x - 1);
    }
    
    • Explain what “measure strictly decreases” means for proving termination here.
  6. Choose parameter passing style to express intent
    Refactor signatures to avoid copies when reading, and to make mutation/consumption explicit.
    #include <vector>
    #include <string>
    
    // ❌ unclear intent / unnecessary copies
    int    sum(std::vector<int> v);
    void   append(std::vector<int> v, int x);
    void   set_name(std::string s);
    
    // TODO: Rewrite as:
    // int    sum(const std::vector<int>& v);            // read-only
    // void   append(std::vector<int>& v, int x);        // modifies caller
    // void   set_name(std::string&& s);                 // consumes temporary
    
    • Give a one-line rationale for each choice (value / const& / & / &&).
  7. Worked clean-up: fix the calls
    Apply all the above: remove nesting, separate side effects, and make passing style explicit.
    #include <algorithm>
    int f1(int){ return 5; }
    int f2(int){ return 7; }
    int i = 0;
    int g(){ return i++; }
    int h(){ return i++; }
    
    int main() {
        int z  = f1( f2( f1(1) ) );      // ❌
        int r  = std::max(g(), h());     // ❌
    
        // TODO:
        // int a = f1(1);
        // int b = f2(a);
        // int z2 = f1(b);
        // int ga = g();
        // int hb = h();
        // int r2 = std::max(ga, hb);
    }
    
Programming Insight (AI) — Call-Site Coach
  • Signature audit: “List each function’s overload set and show which call resolves where (or fails).”
  • De-nesting pass: “Rewrite nested calls into intermediates; generate a pipeline() function.”
  • Side-effect finder: “Flag multiple-argument calls where expressions mutate the same state.”
  • Passing style fixer: “Suggest const&/&/&& based on size, mutability, and ownership intent.”

Lesson 6 extension · select, transfer, return and verify

Which operation receives the call, and what crosses the boundary?

Parentheses make a function call easy to recognise. Recognition is not yet an explanation. At a call site, the compiler must find a callable declaration, decide which operation the arguments can use, initialise that operation's parameters and give the returned value a destination. Each step can be correct in isolation while the programmer has still called the wrong operation.

This is the distinction worth keeping: successful compilation proves that C++ found a permitted interpretation of the call. It does not prove that the interpretation matches the requirement. We need both accounts, the language account and the program account, and they must agree.

Follow one call all the way back to its receiver

#include <iostream>

int add(int left, int right) {
    return left + right;
}

int twice(int value) {
    return value * 2;
}

int main() {
    const int subtotal{add(12, 8)};
    const int total{twice(subtotal)};
    std::cout << total << '\n';
}

Start with add(12, 8). The argument expressions produce the values 12 and 8. Those values initialise the separate parameter objects left and right for this invocation. The function body adds the parameter values, and return supplies 20 as the result of the call expression. That result then initialises subtotal.

The second call repeats the mechanism rather than sharing the first call's parameters. The expression subtotal produces 20, a new parameter object called value is initialised for twice, and the returned 40 initialises total. The parameter objects belong to their individual invocations and reach the end of their lifetimes when those calls finish.

Call eventValues crossing inValue crossing outReceiver
add(12, 8)left becomes 12; right becomes 820subtotal
twice(subtotal)value becomes 2040total

We could write twice(add(12, 8)). Here the inner call must finish because its returned value is the argument required by the outer call. Read the data dependency from the inside out: 12 and 8 enter add, 20 leaves it and enters twice, then 40 leaves twice. This necessary nesting is different from assuming that separate arguments to one call are evaluated from left to right.

The named variable subtotal is not automatically superior, but it gives the intermediate value a meaning and a place to inspect it. Compression is useful only while the transfer of values remains obvious. If a debugger, diagnostic or human explanation needs the intermediate state, the extra name has earned its place.

Exact type matching is too simple to be the rule

Suppose the visible declarations include draw(int) and draw(double). For draw(2), both declarations can be considered, but the integer overload can accept the integer argument without conversion and is the better match. For draw(2.0), the double overload has the corresponding advantage. C++ has selected between viable candidates; it has not required every possible call to begin with identical argument and parameter types.

For these introductory examples, use three questions:

  1. Which declarations with this name are visible at the call site?
  2. Can the supplied arguments initialise their parameters?
  3. If more than one candidate is viable, which has the better conversion sequence?

This is deliberately a beginner's model rather than the full overload-resolution specification. It is still strong enough to replace the false instruction to match every type exactly. A permitted conversion may make a call viable, several viable candidates may leave an ambiguous choice, and no viable candidate leaves the call ill-formed. The return type alone does not choose between ordinary overloaded functions.

Now imagine an ambiguous call repaired by inserting static_cast<int>. The compiler becomes quiet. What has that proved? It proves that the cast steered selection towards an integer operation. It does not prove that discarding a fractional part or choosing that operation matches the problem. Decide the meaning first; then make the argument type or the interface state it. Silence from the diagnostic is necessary evidence, not sufficient evidence.

A forward declaration has a narrower purpose. It makes a name and type available before the definition appears. It does not run the function, reserve a permanent parameter object or solve a confused dependency. If two components require declarations from each other, the program may compile while the design still deserves inspection. Draw the dependency arrows and ask why both directions are needed.

Do not hide required order inside sibling arguments

Consider consume(read_next(), read_next()). One call to read_next will complete before the other, so their executions do not interleave. C++20 does not, however, require the left argument to be evaluated before the right argument. The body of consume begins only after both argument evaluations and parameter initialisations are complete.

// Avoid making the result depend on which argument is evaluated first.
consume(read_next(), read_next());

// Make the required order explicit.
const auto first = read_next();
const auto second = read_next();
consume(first, second);

If the program requires the first read to occur before the second, the original call has hidden a requirement that the language does not promise to honour. Separate statements make the sequence part of the source-level account. The problem was not nesting or multiple arguments by themselves. It was coordinating order-sensitive side effects through an unspecified relative order.

Notice the contrast with twice(add(12, 8)). The outer call cannot proceed until the inner call produces its argument, so the value dependency establishes the required order. In consume(read_next(), read_next()), neither sibling argument depends on the other's result. Their written position is not a portable sequencing instruction.

Parameter form should admit the responsibility

A by-value parameter creates a separate parameter object, as add and twice demonstrate. Changing that parameter does not change the caller's object. A reference to const refers to an existing object and prevents modification through that reference. A non-const reference permits the function to modify the caller's object, so that effect should be visible in the function's purpose rather than discovered by accident.

Cost matters, but intent comes first at this stage. Passing a small integer by value states the required relationship plainly. Passing a larger object by reference to const can avoid a copy while admitting observation rather than mutation. Pointer ownership and rvalue-reference policies belong with the later memory and object-lifetime material; adding advanced punctuation to a signature is not evidence of better design.

Programming Insight (AI): require a call account, not a compiling cast

When an AI tool proposes or repairs a call, require four pieces of evidence: the declarations visible at that point, the viable candidates, the conversion required for each argument and the selected overload. Compare its account with the compiler diagnostic and reduce the case when they disagree. If the proposed fix is a cast, ask which program requirement authorises the conversion and what information it may lose. "It compiles" describes one observation. It does not finish the review.

Build the call ledger

Trace twice(add(12, 8)) on paper. Give every invocation its own parameters, write the value returned at each boundary and name the expression or object that receives it. Then place draw(int) and draw(double) beside the calls draw(2) and draw(2.0). For each call, cross out the candidates that are not viable and explain why the selected conversion sequence is better.

Finish with consume(read_next(), read_next()). Write two permitted traces, one for each relative argument order, and decide whether the observable program result can differ. If it can, replace the call with named intermediate statements and state the order now guaranteed by the source. A useful call explanation accounts for selection, values, effects and return. Familiar parentheses account for none of them.

Reveal answer

The nested call

StageAccount
add(12, 8)The argument values 12 and 8 initialise separate parameters left and right.
Inside addleft + right produces 20, and return makes 20 the value of the inner call expression.
twice(...)The returned 20 supplies the argument value that initialises a new parameter object called value.
Inside twicevalue * 2 produces 40, and the outer call returns 40.
Final receiverIn const int result{twice(add(12, 8))};, the returned 40 initialises result.

The inner call must complete before its value can initialise the outer parameter. That dependency establishes the order; the nesting is not merely visual.

The overloads

Calldraw(int)draw(double)Selection
draw(2)Viable with an exact matchViable after converting int to doubledraw(int), because the exact match has the better conversion sequence
draw(2.0)Viable after converting double to intViable with an exact matchdraw(double), because the exact match has the better conversion sequence

No candidate should be crossed out as non-viable in either of these calls. One candidate is less preferred. That is different from being impossible.

The sibling arguments

Suppose successive calls to read_next() produce 10 and then 20.

  • If the left argument is evaluated first, the left expression receives 10 and the right expression receives 20, producing consume(10, 20).
  • If the right argument is evaluated first, the right expression receives 10 and the left expression receives 20, producing consume(20, 10).

C++20 requires one argument evaluation to complete before the other, but it does not specify which sibling argument is evaluated first. The observable result can therefore differ when consume distinguishes its first and second parameters, or when the reads themselves have observable effects.

const auto first = read_next();
const auto second = read_next();
consume(first, second);

The first declaration completes its read before the second declaration begins. The second read then completes before the call. The source now guarantees that the first value read becomes the first argument and the second value read becomes the second argument.