Defining and Invoking Functions
The logic of a C program is typically broken down into several
functions. A function encapsulates a specific procedure or
functionality, such that the procedure can be “called” whenever and
wherever needed. A function can also call other functions, and can
even call itself recursively, directly or indirectly. The program
starts by calling the main
function. We now review functions as a
language construct, as well as the mechanism and semantics of function
call. We also detail the main
function.
Function Definition
At a very basic level, a function defines some code and gives it a name, so that the code can be referenced and ultimately executed from various parts of the program. Functions are the most basic mechanism for modularization in C. A function most often encapsulate a general computation that can then be applied to specific data. Thus the function definition can also define a set of parameters that the code uses as variables and whose initial values are set for each invocation by the corresponding function-call expression, as we’ll see in detail later.
As an example, consider a game program that needs to print the game
score in many phases of the game and therefore in many parts of the
program. The code that prints the score could be encapsulated in a
function called print_score
such as the one displayed below. The
function is defined with a parameter points
that represents the
score to be printed.
void print_score (int points) { if (points < 0) { printf("Something is wrong: your score is negative!\n"); } else { printf("You have %d point", points); if (points != 1) printf("s"); printf("\n"); } }
Functions are also often intended to compute something for the caller.
So a function can also return a value. The type of the return value
is also specified in the function declaration or definition. The
print_score
function is declared as having a void
return type, but
that isn’t a real object type, and instead means that the function
does not return a value at all. Any other return type specification
indicates that the function returns some value to the caller. Here’s
an example:
int is_prime (int n) { for (int d = 2; d*d <= n; ++d) if (n % d == 0) return 0; return 1; }
This should be pretty clear. So, let’s move on to the semantics of function calls.
Function Calls
This is an example of a program that calls the is_prime
function
defined above.
int main () { for (int i = 1; i < 100; ++i) printf ("%d -> %d\n", i, is_prime(i)); }
Did you see the function invocation in there? (Don’t just skip or
skim the code!) Here the value returned by is_prime(i)
is used
directly as an argument to another function invocation (printf
). In
fact, a function invocation is an expression that can therefore be
used as a sub-expression. Alright, alright. I know what you’re
thinking. This is pretty simple: you first define a function f
that
you then call in other expressions—big deal, let’s move on. True,
this should be pretty straightforward. Things can get a bit more
complicated in C/C++, since in addition to referring to a function by
name, you can in fact do that with an arbitrarily complex expression.
But that is a feature that we’ll see some other time.
What we should discuss instead is the semantics of function calls, and in particular the way that the invocation determines the values of the parameters seen by the code of the function. This, too, should be already pretty clear, but a quick refresher is definitely worth our while. The rule can be stated very simply with a bit of technical lingo: in C, function calls are always call-by-value. Now, I assume you understand the term always. It isn’t a technical term: it means ALWAYS. But what do we mean by call-by-value? This one is also simple, but let’s review. We first of all need to clearly distinguish the parameters, which are listed in the function definition, from the arguments, which are the expressions used in a function call. Here comes the example:
int gcd (int a, int b) { /* parameters: int a, int b */ while (a != b) { if (a > b) { a -= b; } else { b -= a; } } return a; } int main () { int n,m; printf ("input two non-negative integers: "); scanf ("%d%d", &n, &m); printf ("the least common multiple between %d and %d is", n, m); printf ("%d\n", n / gcd (n,m) * m); /* arguments: a <- n; b <- m; */ }
We define a gcd
function with two parameters: int a
and int b
,
and we then call that function with arguments n
and m
. The
parameters are variable declarations. The arguments don’t have to be
variable names and instead can be arbitrary expressions. For example,
we might as well have called gcd (n+7, m*2 + 1)
. So, what happens
when the program executes the function call? The call executes the
code of the function with the parameters defined as additional local
variables each initialized with the corresponding argument. This is
what we mean by call-by-value. (Feel free to go back and read this
paragraph one more time.) The following code illustrates what happens
in the execution of the main
function of the example above.
int main () { int n,m; printf ("input two non-negative integers: "); scanf ("%d%d", &n, &m); printf ("the least common multiple between %d and %d is", n, m); int temp; /* code equivalent to: gcd (n,m) */ { int a = n; /* parameter passed by value */ int b = m; /* parameter passed by value */ while (a != b) { if (a > b) { a -= b; } else { b -= a; } } temp = a; } printf ("%d\n", n / temp * m); }
The code above is an in-line inclusion of the code corresponding to
the call gcd(n,m)
, and it is exactly equivalent to the actual
execution of that call. The key point here is that, with
call-by-value semantics, the code of the function operates on copies
of the arguments that therefore will never be modified by the function
(not directly, that is).
The main Function
The execution of a program starts with the main
function. As an
entry point into the program, the main
function serves as an
interface to the invocation of the program. The specific mechanisms
by which programs are invoked are platform specific and are outside
the scope of C/C++. What C and C++ provide is an interface whereby,
through the main
function, a program can access an array of strings
representing the arguments with which the program is invoked. The
following is a program that prints all its invocation arguments:
#include <stdio.h> int main (int argc, char * argv []) { for (int i = 0; i < argc; ++i) { printf ("arg[%d] = \"%s\"\n", i, argv[i]); } }
Thus conceptually the main
function takes a vector of strings
represented with two parameters: an integer typically called argc
that gives you the number of arguments, and an array of strings
typically called argv
that contains the values of those arguments.
We typically invoke programs using a command shell. I mean, we
hackers typically use a command shell. And as hackers, we like to
understand exactly what happens between the shell and the C program we
invoke through the shell. Here is a common example that uses the ls
program.
$ ls -l *.c
The shell breaks down this command “line” into a list of components,
interpreting spaces as separators, and also expanding some special
characters (or other commands). In this case, the shell expands *.c
into the list of file names in the current directory that end with
.c
(suffix). So, suppose there are two such files in the current
directory called hello.c
and printargs.c
. So, the shell breaks
down the command line into the sequence ls
-l
hello.c
printargs.c
. Then the shell looks for an executable program called
ls
, and if it finds one, say /bin/ls
, it invokes that program with
the argument vector ls
-l
hello.c
printargs.c
, such that the
main function in /bin/ls
is called with argc = 4
and argv =
{"ls", "-l", "hello.c", "printargs.c"}
. To see this clearly, compile
the above program into an executable called printargs
in the current
directory, and run it with the the same arguments in the ls
command.
This is what you should get:
$ ./printargs -l *.c arg[0] = "./printargs" arg[1] = "-l" arg[2] = "hello.c" arg[3] = "printargs.c"
The main
function is a bit special. It cannot be used anywhere in
the program, so it cannot be called (recursively). It is declared as
having an int
return value, but it doesn’t have to contain a
return
statement. If it terminates without executing a return
statement, it still effectively returns 0
. The return value
represents the exit status of the program. The specific
interpretation of the exit status is system dependent. However, the
stdlib.h
header defines two constants, EXIT_SUCCESS
and
EXIT_FAILURE
, that represent a “success” or “failure” status,
respectively. For example:
#include <stdio.h> #include <stdlib.h> int main (int argc, char * argv []) { if (argc == 2) { printf ("Ciao %s!\n", argv[1]); return EXIT_SUCCESS; } else { printf ("usage: %s <your-name>\n", argv[0]); return EXIT_FAILURE; } }