User-Defined Functions
Instead of simply calling a pre-defined functions, C also allows us to write our own function. To put it simply, a function is a named abstraction of a computation. By calling the name of the function, we can perform the computation.
One of the important property we want in a function is modularity. This means that the same function should behave in the same way in different context. By context, we mean the set of mapping from name to values.
So how do we make a function behave differently? That's where the parameters come in. Parameters are the way we can communicate with a function. By starting with different values of parameters, the function may give different computation. The function then communicate back the result via the return value.
Without the parameters, the function is equivalent to a constant value. Take a function that always returns the value 42, for instance. Every call to this function can always be replaced by 42 without changing the behaviour of the program.
Defining a Functions
There are two parts to a function definition. The first is the declaration of function prototype and the second is the function definition that contains the computation.
Syntax
Function Prototype | |
---|---|
1 2 |
|
Function Definition | |
---|---|
1 2 3 |
|
Example
To illustrate this, let us try to solve the following problem:
Washer
Compute the volume of a flat washer. The dimensions of a flat washer are usually given as the following values:
- Inner diameter
- Outer diameter
- Thickness
We assume that all the dimensions are given in a standard unit.
If we abstract the flat washer mathematically, we get a structure that looks like a ring or a donut from the top. Of course, the thickness is hidden here. But we can still compute the area of the rim.
Assuming that we have the following mapping:
- Inner diameter: d1
- Outer diameter: d2
We can calculate the area as the the area of the outer circle subtracted with the area of the inner circle. The computation of the area can be done using the formula \(\pi\)r2, where r is the radius. But what we have is the diameter and not the radius. So first, we have to compute the radius by simply halving the diameter. This gives us the formula below:
rim area = \(\pi\)(d2 \(\div\) 2)2 - \(\pi\)(d1 \(\div\) 2)2
Translating that into code, with all the preamble, we get the following code:
Washer.c | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
But wait! We have not used a user-defined function! Note that the computation of the area of the circle is duplicated. In particular, if we wish to change the shape (e.g., to square where the input parameter now denotes the length of the sides) then we have to change both parts of the computation. This is prone to error.
Instead, we can abstract the computation of the area of a circle as a function. The function can be defined as follows:
Circle Area | |
---|---|
1 2 3 |
|
The resulting code will be the following. The function prototype, function definition and function calls are highlighted.
WasherV2.c | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
|
Function Prototype
Why do we need function prototype? There is actually not much of a "good reason" except to make the compilation more efficient. Other modern programming languages like Java do not require function prototype and can still compile without warning. This comes at the expense of multiple-pass by the compiler. The first pass is to collect all the function definition and extract the function prototype and the second pass is to compile using this collected function prototype1. On the other hand, C compiler can do this with a single-pass because of the function prototype.
Good Practice
The good practice in C is to do the following:
- Put function prototype at the top of the program before the
main()
function.- This informs the compiler about the functions that your program may use as well as their return types and parameter types.
- Include only the function's return type, the function's name and the data types of the parameters.
The names of the parameters are optional.
- This allows the implementation to rename the parameters easily.
- Put the function definition after the
main()
function.
Note that without the function prototypes, you will get error/warning messages from the compiler.
By default, the compiler assumes the default (implicit) return type of int
for any function without prototype where the definition is given after the function call.
You can check this by trying to remove the function prototype for circle_area
in WasherV2.c
or click on the "ReplIt" tab.
The template below captures the good practice with respect to function prototype.
Template | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
Combining Prototype and Definition
This is not a good practice, so proceed with care.
We can combine the function prototype with function definition as long as the function definition is given before the function call.
In most cases, it means that the function definition is given before the main()
function.
However, note that this will fail for mutually recursive functions.
For example, if a function f
calls function g
and function g
calls function f
, then there is no way we can satisfy:
function definition is given before the function call
except through the use of function prototype.
WasherV3.c | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
Scope
C uses static scoping also called lexical scoping. There are three kinds of variables in C:
- Global Variables
- Variables declares outside of all functions.
- The variables are available to all functions.
- Local Variables
- Variables declared inside a function, including the function parameters.
- The variables are available only within the functions.
- Static Variables
- Variables declared inside a function, with a
static
keyword. - The variables are available only within the functions.
- The values in the variables persist across function call.
- Variables declared inside a function, with a
Details
When a function is called:
- An activation record is created in the call stack.
- Memory is allocated for the parameters and local variables of the function.
The details on how the activation record is created depends on the compiler. When the function is done:
- The activation record is removed from the call stack.
- Memory allocated to the parameters and local variables are released.
Local vs Static
The main effect of local variables is that parameters and local variables of a function exist in memory only during the execution of the function. They are called automatic variables. In constrast, static variables exist in memory even after the function is executed.
We can visualise the global and local variables as a nested block of codes. However, we will exclude static variables from our discussion.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
1 2 3 4 5 |
|
6 7 8int x = 3; // Local variable (shadowing the global variable) printf("res = %d\n", adder(x)); // adder(3) return 0;
9 10 11 |
|
12
return x+y; // both x and y can be used
13 |
|
Scoping Rule
To find a variable, you can go out of a function but can never go into another function.
Luckily for C, there is no problem with nested scoping. This is because nested functions are not allowed. In other words, you cannot define a function inside another function. Furthermore, if we follow ANSI C, then variable declarations have to be at the start of the function. As such, if we follow ANSI C, then we cannot have a variable declaration within a block. This makes the scoping rule very simple.
Quick Quiz
What is wrong with this code?
1 2 3 4 5 6 7 8 9 10 11 |
|
Variable a
is local to the function main()
and not f()
.
Hence, variable a
cannot be used inside f()
.
Pass-by-Value
When we call a function, there are several operations happening behind the scene.
- Evaluate the arguments.
- Copy the arguments to parameters positionally (i.e., 1st argument to 1st parameter, 2nd argument to 2nd parameter, and so on).
- Evaluate the function body.
- Copy the return value back to the caller (if any).
Distance.c | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
- Evaluate the arguments.
3.2
↦3.2
12/5
↦2
- Pass the arguments to parameters positionally (i.e., 1st argument to 1st parameter, 2nd argument to 2nd parameter, and so on).
x
↤3.2
y
↤2
(implicit conversion to2.0
)
- Evaluate the function body.
sum_square = pow(x,2) + pow(y,2);
↦sum_square = pow(3.2,2) + pow(2.0,2);
↦sum_square = 10.24 + 4.0;
↦sum_square = 14.24;
- Pass the return value back to the caller (if any).
return sqrt(sum_square);
↦return sqrt(14.24);
↦return 3.77;
- Evaluate the arguments.
a
↦10.5
a+b
↦10.5+7.8
↦18.3
- Pass the arguments to parameters positionally (i.e., 1st argument to 1st parameter, 2nd argument to 2nd parameter, and so on).
x
↤10.5
y
↤18.3
- Evaluate the function body.
sum_square = pow(x,2) + pow(y,2);
↦sum_square = pow(10.5,2) + pow(18.3,2);
↦sum_square = 110.25 + 334.89;
↦sum_square = 445.14;
- Pass the return value back to the caller (if any).
return sqrt(sum_square);
↦return sqrt(445.14);
↦return 21.10;
Order of Evaluation of Argument
There is actually no fixed order of evaluation of argument in C standard. In fact, we can see that the two most common compilers GCC and Clang have different order of evaluation. The order of evaluation of Clang is the usual left-to-right. On the other hand, the order of evaluation of GCC is actually right-to-left.
This ambiguity is intentional especially when we consider parallel and/or concurrent execution. In such cases, the compiler may choose to evaluate all at the same time. The reason GCC choose right-to-left evaluation is because of speed.
Remember, we create the activation record during function call. This is created in a stack. So, by evaluating from the right, we can immediately push it into the stack. As a consequence, the left-most parameter is simply at the top of the stack now. This behaviour is only useful for variadic function where we do not know how many arguments are going to be passed.
As a programmer, you should be careful not to depend on the order of evaluation for the correct execution of your program.
To run in Clang, simply click the run button. To run in GCC, follow the instructions below:
- Click "Shell" tab.
- Compile with GCC using
gcc main.c
. - Execute the code using
./a.out
.
Notice how the result is different between Clang and GCC.
Using Clang, you should see the output as 0 1 2
.
On the other hand, using GCC, the output is 2 1 0
.
Quick Quiz
Trace the following code by hand and write out its output.
Note that a void
function is a function that does not return any value.
PassByValue.c | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
1 2 3 4 |
|
Consequence of Pass-by-Value
Let's go back to the motivation for learning pointers.
Can we use the following function to swap the values in a
and b
below?
SwapIncorrect.c | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
You should get the following output:
1 2 |
|
What is happening?
Why is it that we cannot modify the variable a
and b
using the function swap
?
The answer is that pass-by-value copies the value from arguments to parameters.
This means that we have two copies of the value 2
(respectively 3
).
- The value in variable
a
(respectivelyb
), accesible inmain
. - The value in variable
a
(respectivelyb
), accesible inswap
.
Although the variable name is the same, they actually refer to different variables due to scoping.
So now, any changes in a
(respectively b
) inside swap
will only affect the local copy.
Hence, the value of a
(respectively b
) in main
is unchanged.
The visualisation is shown above.
Notice that the changes inside swap
does not affect main
.
Pass the Reference
The consequence of pass-by-value brings us back to the use of pointers.
If we really want to modify the content of variable a
(respectively b
) in main
using the function swap
, then we have to allow for indirect access of the variable.
This is because direct access is impossible due to the scoping rule.
How do we give the indirect access to the function swap
?
We have to pass the address of the variable!
To do so, the function has to accept a pointer.
SwapCorrect.c | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
The box-and-arrow diagram is shown above. Note the difference when a pointer is used.
Passing Address Accept Pointers
It is a common practice to pass address into a function that accept pointers. However, this need not be the case. We can extract the address immediately and store it into a pointer variable first. Afterwards, we then pass the value of this pointer to the function. But remember, the value of a pointer is an address!
Quick Quiz
Which library function you have learnt that accepts an address? In other words, the parameter is a pointer.
scanf()
Example1.c | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
Example2.c | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
Example3.c | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
Example4.c | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
-
There may be more passes but typically just two is enough. ↩