Lesson 10 / 13
Functions
A call being bound to a definition in another unit being resolved at the linker stage; the declaration–definition distinction, parameter passing always being by copy, and the return value's type determining the caller side's interpretation.
Published Updated
In this lesson
- Measures that a call being bound to a definition in another unit is resolved at the linker stage
- Shows the separation of declaration from definition and that parameter passing is always by copy
- Distinguishes with an example how the return value's type determines the caller's interpretation
Contents
The Control Flow lesson dealt with a question that always stayed inside the same function: which branch gets chosen. This lesson lifts that boundary and measures how a call crosses a function’s boundary. When a function is called, three separate questions are resolved at once: which definition the call is bound to, how the arguments reach the function, and how the returned value is interpreted — all three decided at the same time, but for different reasons. The first belongs to the linker, because it requires more than one unit; the other two are decisions the compiler makes by looking at a single unit — even without knowing exactly where a call will be bound, the compiler can work out from the declaration how the arguments will be copied and how the return will be interpreted.
The Programming Fundamentals course (M01/K02) already established the concept of a function with Python; there, a function only needed to be defined before it could be called, and there was no declaration–definition distinction. C’s counterpart is measured here: a call can be compiled by relying only on a declaration, without ever seeing the definition — the definition itself can sit in another unit, and the step that binds the two sides together belongs to a separate stage. The three decisions measured in this lesson — binding, passing, return — are three separate components of a function’s calling surface, and each is fixed at its own stage, independently of the others.
The Linker: A Function in Another Unit
The excerpt below is part of the core introduced in the Compilation Stages lesson: a function is called in one unit, while its definition sits in another.
// stage.h — declarations of names defined in other units (excerpt: function declaration) #ifndef STAGE_H #define STAGE_H /* This function is defined in another unit: its call is resolved at link time. */ int identity(void); #endif
// choice.c — the second unit: the function's definition lives here
#include "stage.h"
int identity(void) {
return 7;
}
// stage.c — the linking of a function in another unit (excerpt)
#include <stdio.h>
#include "stage.h"
int main(void) {
printf("%s %d\n", "function in another unit", identity());
return 0;
}
function in another unit 7
BS8. While stage.c is compiled, identity’s body appears nowhere — the compiler
sees only the declaration in stage.h, and from that declaration checks that the call
takes the right number of arguments of the right type, that the return type is int, and
leaves a “I need this name” note in the object file. choice.c is compiled separately
and writes identity’s real body into its own object file. When the two object files are
brought together, the linker matches the note stage.c left with the body choice.c
supplies — without this match, an executable program never comes into being at all.
Scope and storage duration were questions that stayed inside a single unit; which body a
call is bound to, on the other hand, is resolved only once units come together, at the
linker stage.
This three-stage flow — first the two units are compiled separately, then the linker
merges the two — is the general pattern measured in the Compilation Stages lesson,
applied to functions. Compiling stage.c proceeds unaware of whether choice.c even
exists; the compiler works only by looking at the declaration in stage.h, under the
assumption “a function with this name, with this signature, must be defined somewhere,”
and confirming that assumption is the linker’s job. If choice.c had never been
compiled, or identity’s definition had been mistakenly deleted, compiling stage.c
would still complete without trouble — the error would only surface at the linking step,
when the linker cannot find the name identity in any object file. This shows why a
compile error and a link error can surface at different stages, at different times.
This mechanism belongs to the same family as the extern variable binding measured in
the Variables, Scope and Storage Duration lesson: there, which storage location a name
corresponds to, here, which body a name corresponds to, is resolved at the linker. The
difference is that functions have external linkage by default — while a variable needs
extern written to be shared, a function is already visible and callable across units
unless it is written static. Because the identity function is not static, its
definition in choice.c can be seen from stage.c; had the function been defined as
static identity(void), it would close into its own unit, and compiling stage.c would
fail because the linker could not find the same name.
This confirms that the static keyword performs the same function for functions as it
does for variables: it narrows scope to the file boundary and prevents the linker from
exposing that name to other units. Declaring a function static explicitly marks in the
source that the function is only a helper belonging to its own unit’s internal working,
not offered as an interface to the outside; this is the function-level counterpart of a
unit being able to hide its own internal details.
The Separation of Declaration from Definition
A declaration tells the compiler a function’s signature (name, parameter types, return
type); a definition also includes the body. int identity(void); in stage.h is a
declaration — it ends with a semicolon, it carries no body. int identity(void) { return 7; } in choice.c is a definition. A function can be declared as many times as wanted
across different units (through the same header file), but it must have exactly one
definition — otherwise the linker finds more than one body for the same name, and this is
a contradiction. This separation is the function-level application of the
declaration–definition distinction measured for variables in the previous lesson: extern int unit_counter; was a variable declaration while int unit_counter = 2; was the
definition; here the same pattern repeats, with a declaration carrying only the signature
in place of the body.
Without a function declaration, it is not possible to check whether a call is correct
just by looking at how it is called; the compiler learns how many parameters are expected
and of what type only from the declaration. stage.h being included by stage.c
provides exactly this: while stage.c is compiled, it never sees identity’s body, but
it knows it is a function taking no parameters and returning int, and it compiles the
call accordingly. A declaration is therefore not merely documentation — it is the minimum
information the compiler needs to produce the call correctly.
Parameter Passing: Always a Copy
In C, every argument passed to a function is copied into the function’s own parameter. A change made to the function’s parameter never affects the caller side’s variable.
// copy.c — parameter passing is always a copy, it never affects the caller's variable
#include <stdio.h>
static void double_value(int value) {
value = value * 2;
printf("%s %d\n", "value inside the function", value);
}
int main(void) {
int number = 5;
double_value(number);
printf("%s %d\n", "number on the caller side", number);
return 0;
}
value inside the function 10 number on the caller side 5
The measurement prints two lines: value inside the function, 10 after the
multiplication; number on the caller side is still 5. When double_value is called,
number‘s value (5) is copied into a new storage location named value; the change
inside value affects only that copy, it never touches number in main. When the
function returns, value disappears — an instance of the automatic storage duration
measured in the Variables, Scope and Storage Duration lesson — and number remains
untouched. This behavior is the same regardless of the type of parameter the function
takes (integer, floating point, struct): passing in C is always pass-by-value; the
source’s own variable never passes directly into the function.
This copying works the same way regardless of how many parameters there are, and is
independent of the function body’s length: the name value corresponds to a storage
location separate from both the caller’s number and the function’s own body, created
the instant the function is called and disappearing when it returns. This shows that the
automatic storage duration rule measured in the Variables, Scope and Storage Duration
lesson also applies to parameters: a parameter behaves just like an ordinary local
variable declared inside the body — only its initial value comes from the caller side. A
source writer who genuinely wants to modify the caller’s variable does this in C not by
passing the value, but by passing that value’s address; but the address itself is not
printed in this lesson, and this subject belongs to the pointers subject, to which an
entire course is devoted.
The Return Value’s Type
A function’s return type determines how the caller interprets that value, independently of which expression is computed inside the body.
// returns.c — the return type determines how the caller interprets the value
#include <stdio.h>
static int ratio(int numerator, int denominator) {
double actual_ratio = (double)numerator / (double)denominator;
return (int)(actual_ratio * 100);
}
int main(void) {
int percentage = ratio(1, 3);
printf("%s %d\n", "percentage with int return type", percentage);
return 0;
}
percentage with int return type 33
Inside ratio, actual_ratio is a double, and 1 / 3 is computed as floating-point
division rather than integer division (0.333...). But because the function’s return
type is declared int, the value in the return expression — 33.333... — is converted
to int before it reaches the caller, and the fractional part is dropped: 33. This
conversion is applied not by looking at where or which type is used inside the function
body, but solely at the function’s declared return type — just as a compound
assignment’s result was forced into the left side’s type, here the result is forced into
the type written in the function’s signature. Different calls like ratio(1, 3) and
ratio(1, 4) produce different actual ratios, but both go through the same return
expression, the same narrowing-to-int rule — regardless of which input it is called
with, the conversion rule itself never changes.
Where this conversion happens matters: the expression actual_ratio * 100 is still
computed at double width, inside ratio‘s body; narrowing to int is applied only as
the return expression’s value leaves the function, exactly at the function’s boundary.
The caller side (main) never knows and does not need to know which intermediate types
were used inside ratio — it only sees the declared return type (int) and assigns the
incoming value to a variable of that type (percentage). This shows that a function’s
boundary provides a kind of encapsulation: whatever intermediate types the
computation inside the body uses, the only piece of information that leaks outward is the
return type written in the signature.
The Bounding Measurement
A call compiles and links safely as long as it is compatible with the called function’s
declaration — the correct.c example below shows this.
// stage.h — the full declaration telling the compiler the call's shape #ifndef STAGE_H #define STAGE_H int identity(void); #endif
// choice.c — the definition carries the same signature as the declaration
#include "stage.h"
int identity(void) {
return 7;
}
// correct.c — when declaration and definition share the same signature, the call links safely
#include <stdio.h>
#include "stage.h"
int main(void) {
printf("%s %d\n", "correctly declared call", identity());
return 0;
}
correctly declared call 7
In this setting, a source trying to call a function whose declaration is never seen is
rejected directly by the compiler — the compiler needs at least a declaration in order to
check a call’s shape, and stops compiling the source without one. But this does not mean
the compiler catches every mismatch. A declaration is checked only within the unit it
sits in; while the compiler compiles stage.c it never looks inside choice.c, and
there is no mechanism verifying that the real definition in choice.c genuinely matches
the declaration. C’s linker also looks only at the symbol name, not the type — if two
units declare and define a function with the same name but different parameter types,
the linker still binds them to each other. Such a call falls into undefined behavior once
compiled, linked, and run, because the caller side prepares the arguments one way while
the called side reads them another; under this course’s general rule, such a mismatch is
not tried in a code block. This lesson runs only the correctly declared setup above: the
declaration tells the compiler the call’s shape, but what guarantees that it genuinely
matches the definition is the source writer’s own consistency — neither the compiler nor
the linker checks this across units.
The practical consequence of this limit is why a header file is kept as a single
source of truth. As long as every unit calling identity includes stage.h, and
choice.c also includes the same header, declaration and definition come from the same
source and automatically stay consistent with each other — the source writer never has to
hand-write the same signature twice in two different places. If a unit did not include
the header and instead hand-wrote the function’s signature in its own source, the two
declarations would sit separately in the source, and one could go stale unnoticed if the
other changed — exactly the source of the risk described above. Sharing the header file
does not eliminate this risk, but it at least guarantees the signature is written once,
and hence consistency coming from at least one source.
Summary
- Which definition a call is bound to is resolved by the linker once units come together; the compiler only checks the signature from the declaration, it never sees the body.
- A declaration carries only the signature (name, parameter types, return type); a definition also includes the body. A function can be declared many times, but is defined exactly once.
- Parameter passing is always pass-by-value: a change made inside the function never affects the caller’s own variable.
- A function’s return value is converted to the function’s declared return type, independently of the types of the intermediate computations inside the body.
- The match between a declaration and that name’s real definition is checked across units by neither the compiler nor the linker; keeping this consistency is the source writer’s job.
- A
staticfunction closes into its own unit and is exposed to no other unit by the linker; the same hiding rule from variables applies to functions as well.
Next Step
All the decisions measured in this lesson were about a function’s calling shape: which definition it is bound to, how its argument is passed, how its return value is interpreted. The Arrays and Strings lesson adds to this ground the question of when a piece of data’s length is known: an array’s element count can be produced at compile time from a constant expression in the source, while a string’s length can only be learned at runtime by scanning until a terminating byte is reached — and the “parameter is always a copy” rule measured in this lesson turns into an interesting exception once an array is passed to a function: when an array name is passed to a function, what is copied is not the array itself, but the address of the array’s first element — and this is also the source of why length information must be carried separately.
To keep your progress and take notes, Log in
My notes
Log in to take notes.