CS211: Functions
Table of Contents
Introduction
The most powerful tool a computer programmer has is abstraction. Crafting abstractions allows us the ability to offload cognitive effort and focus on more generalized problems. For example, when we use our bash shell we often don't worry about the inner workings of how the OS handles the bytes that constitute files or folders. We simply run commands like cd, ls, and mv while taking for granted that these files will be managed appropriately by the filesystem. When constructing large, and even small, programs a principal abstraction is that of the function, which, when implemented correctly, acts as a means of compartmentalizing behavior in the same way that files or their corresponding bash operations do.
Why Functions?
The usefulness of functions becomes readily apparent when applied to a real example. Take the power function we defined when discussing loops:
1: #include <stdio.h> 2: int main(){ 3: int x = 3; 4: int y = 5; 5: int pow = 1; 6: for(int i = 0; i < y; i = i + 1){ 7: pow = pow * x; 8: } 9: printf("%d^%d = %d\n", x, y, pow); 10: }
If I wanted to also find the values of 3^5 and 4^10, I would need to alter the program like so:
1: #include <stdio.h> 2: int main(){ 3: int x = 3; 4: int y = 5; 5: int pow = 1; 6: for(int i = 0; i < y; i = i + 1){ 7: pow = pow * x; 8: } 9: printf("%d^%d = %d\n", x, y, pow); 10: x = 4; 11: y = 10; 12: for(int i = 0; i < y; i = i + 1){ 13: pow = pow * x; 14: } 15: printf("%d^%d = %d\n", x, y, pow); 16: }
In order to achieve the two outputs I had to copy and paste my power code a second time into my main function. But what if I also wanted to see the values of 7^3 and 16^6?
1: #include <stdio.h> 2: int main(){ 3: int x = 3; 4: int y = 5; 5: int pow = 1; 6: for(int i = 0; i < y; i = i + 1){ 7: pow = pow * x; 8: } 9: printf("%d^%d = %d\n", x, y, pow); 10: x = 4; 11: y = 10; 12: for(int i = 0; i < y; i = i + 1){ 13: pow = pow * x; 14: } 15: printf("%d^%d = %d\n", x, y, pow); 16: x = 7; 17: y = 3; 18: for(int i = 0; i < y; i = i + 1){ 19: pow = pow * x; 20: } 21: printf("%d^%d = %d\n", x, y, pow); 22: x = 16; 23: y = 6; 24: for(int i = 0; i < y; i = i + 1){ 25: pow = pow * x; 26: } 27: printf("%d^%d = %d\n", x, y, pow); 28: 29: }
It all gets to be a bit much, no? What if instead we had a power function to represent the computation, like so:
1: #include <stdio.h> 2: int main(){ 3: printf("%d^%d = %d\n", 3, 5, pow(3,5)); 4: printf("%d^%d = %d\n", 4, 10, pow(4,10)); 5: printf("%d^%d = %d\n", 7, 3, pow(7,3)); 6: printf("%d^%d = %d\n", 16, 6, pow(16,6)); 7: }
It looks much cleaner and is easier to follow. The only issue is now we need to define a function called pow…
Defining Functions
Functions in programming languages work similarly to functions in pure mathematics: they take input and they produce output. The syntax for defining a function in C is as follows:
1: type function_name(arg1, arg2, ...){ 2: statements; // Do this 3: return val; //Return some data (output) 4: }
Like variables, every function must have a type—for functions, it is called a return type. By default, all functions in C have the int return type if not otherwise specified. Functions may or may not take arguments; however, if arguments are specified in the function definition that function must be passed arguments. Arguments must be sent in the order in which they were defined. Functions may or may not specify a return value; the return value must match the type of the defined return type1.
In C, we can represent the identity function f(x) = x as:
1: int id(int x){ 2: return x; 3: }
Despite the small amount of syntax there are a few things we can notice. First, the function has a name, id, just like variables do. Knowing a function's name is essential to calling that function. Furthermore, we can see that the id function has a return type, int. Again, the return type indicates the type of data that will be output by that function. Additionally, we can see that there is an argument defined by the id function, x with type int. This means that id takes an integer value when it is called. Finally, we can see a return statement which sends back the value tied to the expression it is provided (here the value of x).
Let's now define the pow function we referenced in Listing 4:
1: int pow(int x, int y){ 2: int acc = 1; 3: for(int i = 0; i < y; i = i + 1){ 4: acc = acc * x; 5: } 6: return acc; 7: }
The fundamentals we learned with id are still present in pow: there is a return type, arguments, a function body, and a return statement within the function body.
Returning Values
I find that students learning about programming for the first time often get tripped up on what it means to "return" a value. The syntax for a return statement is as follows:
1: return expr;
If you thick back to the lecture on expressions, you'll remember that an expression is something that evaluates to some value. It is the value that is returned by the return statement. For example, given the pow function as it's defined in Listing lst:pow_function, we can see that acc is returned. It is not the variable that is returned, but rather the value stored in the variable acc. When we call pow with pow(3,2), the function will reserve space for a varable called acc, eventually store 9 in that memory location, and then ultimately return that 9 to the caller. (Refer to the section on calling conventions for further explanation.)
void Type
Some functions don't need to return a value. For example, we've often used the printf function to print characters to the screen. The function does not return anything, but rather produces side effects, i.e. it changes something about the state of the program which isn't captured in a return value. Functions which do not return anything are tagged with a special void type. For example, I could write a function which prints the results of a call to pow:
1: #include <stdio.h> 2: void print_pow(int a, int b){ 3: printf("%d^%d = %d\n", a, b, pow(a,b)); 4: }
Here, I've created a function print_pow which I've assigned the void return type. Because I've assigned the function void, there is no expectation on the part of the compiler for a return statement to be present.
Variables in Functions
You might be wondering what is going on with the arguments in the function declarations. Why am I treating them like they're normal variables? Well, it's because they are. The only thing special about arguments is that the function caller provides the values in the function call, they are not set a priori. Once a function argument has been declared it can be used like any other variable in the function body.
I mentioned in the variables lecture that scope would eventually be relevant again. This is exactly that time! All variables, including arguments, defined within a function block are visible only in that function block. For example, take pow above (Listing 7). Variables x, y, i, and acc can only be referenced within that given function. If I were to define another function, say is_x_negative, I wouldn't be able to reference the x defined in pow:
1: int pow(int x, int y){ 2: int acc = 1; 3: for(int i = 0; i < y; i = i + 1){ 4: acc = acc * x; 5: } 6: return acc; 7: } 8: 9: int is_x_negative(){ 10: return x < 0 : 1 else 0; // x cannot be referenced here! 11: }
Another thing that trips up students is declaring variables with the same name in two different locations. For example, if I try to call pow with a variable x in main:
1: int pow(int x, int y){ 2: int acc = 1; 3: for(int i = 0; i < y; i = i + 1){ 4: acc = acc * x; 5: } 6: return acc; 7: } 8: 9: int main(){ 10: int x = 10; 11: pow(x, 3); 12: }
The x in main and the x in pow are two entirely different variables. The x on Line 10 is only visible in the main function, whereas the x on Line 1 is only visible in the pow function. This is because their lexical scopes are set at the time of declaration, within the scope in which they are declared. During compilation, variables with the same name are tagged2 with information regarding the location in which they are defined to disambiguate their scope information (or they are assigned unique id's).
Forward Declarations
Functions can be declared without actually being implemented. For example, I could define the following logic in one file called my_program.c:
1: #include <stdio.h> 2: int pow(int,int); // Forward declaration of a pow function with a certain type signature 3: int main(){ 4: printf("3^10 = %d\n", pow(3,10)); 5: }
The above code will compile, but it will not build into an executable. You can see what I mean below:
1: josephraskind@stargazer:/tmp/func$ gcc -Wno-builtin-declaration-mismatch my_program.c -o my_program 2: /usr/bin/ld: /tmp/ccUIN1k5.o: in function `main': 3: my_program.c:(.text+0x13): undefined reference to `pow' 4: collect2: error: ld returned 1 exit status 5: josephraskind@stargazer:/tmp/func$ ls 6: my_program.c 7: josephraskind@stargazer:/tmp/func$ gcc -c -Wno-builtin-declaration-mismatch my_program.c -o my_program.o 8: josephraskind@stargazer:/tmp/func$ ls 9: my_program.c my_program.o
The first call to gcc on Line 1 tries to compile and link the c files it was provided. The linking stage involves trying to match unknown symbols with known symbols. The forward declaration on Line 2 of Listing lst:forward_dec is essentially an IOU. We're telling the compiler, "Trust me, there will be a pow function coming your way that takes two ints and returns an int". The problem is that we never actually supply it. The second call to gcc uses the -c flag which tells the compiler to stop after the compilation stage—don't bother linking any of the compiled files. This is why we get a .o file, or an object file, without any issue. We can solve this one of three ways:
- Define the pow function in
my_program.c. - Write another C file to define
powin and compile it together. - Compile another
.ofile and link the two after each are compiled seperately.
(1) is trivial, and (3) I will leave as an exercise. To solve (2) we can write another C file called my_pow.c which contains the code found in Listing 7 and then compile them together in gcc:
1: josephraskind@stargazer:/tmp/func$ gcc -Wno-builtin-declaration-mismatch my_program.c my_pow.c -o my_program 2: josephraskind@stargazer:/tmp/func$ ./my_program 3: 3^10 = 59049
Here, we make good on the promise that pow will eventually be defined.
Calling Functions
The syntax for calling functions is rather simple, and I couldn't avoid showing it in prior examples so you have seen it before. All you need to call the function is:
- To be in a lexical scope that recognizes the function's name
- Use the name of the function
- Place a set of parentheses next to the name
- Insert the correct number and type of arguments corresponding to each parameter defined in the function header
For example, to call pow all I need to write is pow(3,5). 3 corresponds to the first argument, x, and 5 corresponds to the second argument, y. If that function has a return type then the returned value will be evaluated in function call expression. Therefore, int p = pow(3,5) will store the value 243 into the variable p.
Because function call arguments are expressions we can nest function calls within one another. For example, if I wanted to evaluate the 2^(3^2), I could write pow(2, pow(3, 2)). First the expression pow(3,2) will be evaluated, then the expression pow(2, 9) will be evaluated.
Calling Convention
Functions at the high-level programming language view can look mighty abstract. One thing that helps break down what's involved in implementing functions is taking a look at the assembly code generated by the compiler. Below is the C code I will compile down to assembly:
1: #include <stdio.h> 2: int pow(int x, int y){ 3: int acc = 1; 4: for(int i = 0; i < y; i = i + 1){ 5: acc = acc * x; 6: } 7: return acc; 8: } 9: 10: int main(){ 11: printf("result: %d\n", pow(5, 3)); 12: }
I then use the command gcc -O0 -Wno-builtin-declaration-mismatch -fno-omit-frame-pointer -fno-stack-protector -S calling.c to produce the following assembly:
1: .file "calling.c" 2: .text 3: .globl pow 4: .type pow, @function 5: pow: 6: .LFB0: 7: .cfi_startproc 8: endbr64 9: pushq %rbp 10: .cfi_def_cfa_offset 16 11: .cfi_offset 6, -16 12: movq %rsp, %rbp 13: .cfi_def_cfa_register 6 14: movl %edi, -20(%rbp) 15: movl %esi, -24(%rbp) 16: movl $1, -4(%rbp) 17: movl $0, -8(%rbp) 18: jmp .L2 19: .L3: 20: movl -4(%rbp), %eax 21: imull -20(%rbp), %eax 22: movl %eax, -4(%rbp) 23: addl $1, -8(%rbp) 24: .L2: 25: movl -8(%rbp), %eax 26: cmpl -24(%rbp), %eax 27: jl .L3 28: movl -4(%rbp), %eax 29: popq %rbp 30: .cfi_def_cfa 7, 8 31: ret 32: .cfi_endproc 33: .LFE0: 34: .size pow, .-pow 35: .section .rodata 36: .LC0: 37: .string "result: %d\n" 38: .text 39: .globl main 40: .type main, @function 41: main: 42: .LFB1: 43: .cfi_startproc 44: endbr64 45: pushq %rbp 46: .cfi_def_cfa_offset 16 47: .cfi_offset 6, -16 48: movq %rsp, %rbp 49: .cfi_def_cfa_register 6 50: movl $3, %esi 51: movl $5, %edi 52: call pow 53: movl %eax, %esi 54: leaq .LC0(%rip), %rdi 55: movl $0, %eax 56: call printf@PLT 57: movl $0, %eax 58: popq %rbp 59: .cfi_def_cfa 7, 8 60: ret 61: .cfi_endproc 62: .LFE1: 63: .size main, .-main 64: .ident "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0" 65: .section .note.GNU-stack,"",@progbits 66: .section .note.gnu.property,"a" 67: .align 8 68: .long 1f - 0f 69: .long 4f - 1f 70: .long 5 71: 0: 72: .string "GNU" 73: 1: 74: .align 8 75: .long 0xc0000002 76: .long 3f - 2f 77: 2: 78: .long 0x3 79: 3: 80: .align 8 81: 4:
There is now quite a lot going on here! The first, most important thing to take note of is the fact that there are two labels main: and pow:. These refer to the two functions defined in the source code—this is how Line 52 can have the call pow instruction. However, there are a few key events that happen prior to that call instruction. First, Line 45 has the main function invoking the pushq %rbp instruction. %rbp is a register which stores the base pointer of the previous function's stack. Any startup functions called before main can still be reached with their initial stack contents. Next, movq %rsp, %rbp is processed, which moves the current stack pointer onto the base pointer. Then, Lines 50 and 51 involve setting up the arguments that will be used by the pow function. 3 is placed in %esi, the register used for the second argument, and 5 is placed in %edi the register used for the first argument3. The register order for arguments is settled by the architecture's calling convention.
Once Line 52 has been executed, control is transferred to the pow function. The pow function, like the main function, makes some room for its own stack. Lines 14 and 15 have the argument registers %esi and %edi going into their target variables (i.e., locations on the stack). The function then operates normally until we reach Line 28. Line 28 places the acc variable, location -4(%rbp), into the conventional return register %eax. The pow stack frame is then popped off (it will now be ignored in memory). Line 31 finally returns out of the pow function.
Line 53 has us returning back into the main function and storing the return value sent by pow through %eax into %esi (which should make sense as pow(5,3) is the second argument to our call to printf). printf is called and the main function returns after it is finished.
If it's not clear how the ret call works, take another look at the movq %rsp, %rbp instruction on Line 48 again. When ret is invoked it will take whatever is at the top of the stack and jump to that memory location.
Function Uniqueness
All functions in C must be unique. This means I cannot define two functions with exactly the same name. For example:
1: int my_func(int i); // Initial function 2: 3: int my_func(int i); // Big problem! 4: // I already have a function named my_func 5: // that takes an int and returns an int 6: 7: double my_func(int i); //Big problem, again! 8: // I already have a function named my_func 9: // that takes an int 10: 11: int my_func(int i, int j); // Big problem, yet again! 12: // I already have a function named my_func 13: 14: int my_other_func(int i); // No problem here, different name! 15:
This is an example of where forward declaration could be useful. If there are two libraries with a function called sort_data then you can decide which file to link with during compilation when benchmarking.
Recursive Functions
Sometimes, complex problems are easily defined with recursive solutions. Take the fibonacci sequence for example. It is defined as:
f(0) = 0f(1) = 1f(n) = f(n-1) + f(n-2)- where
n > 1
I leave it as an exercise for you to implement the fibonacci sequence as a recursive function in C.
Exercises
- Given the
idfunction defined in Listing 6, what would be returned by a call toid(15.7)? Would an error be thrown? Why or why not? - The lecture states that all functions in C default to the
intreturn type if not otherwise specified. Write a functionaddthat takes twointarguments and returns their sum, but omit the return type from the function header. Compile it withgcc -Walland record any warnings. Does the program still run correctly? What does this tell you about the difference between a compiler warning and a compiler error? - Rewrite Listing 3 using the
powfunction defined in Listing 7. How many lines of code did you save? What would happen if you discovered a bug in the power logic — how many places would you need to fix it in Listing 3 versus the function-based version? - Write a
voidfunction calledprint_powthat takes two integers and prints the result of raising the first to the power of the second, as shown in Listing 9. Then rewrite Listing 3 usingprint_pow. What is the difference between a function that returns a value and one that produces a side effect? - The lecture states that function arguments are just variables. Write a program that declares
int x = 10inmainand passes it topow. After the call topow, printxinmain. Doesxchange? What does this tell you about the relationship between thexinmainand thexinpow? - Implement the fibonacci sequence as a recursive function in C as suggested at the end of the lecture. Test it with the values 0, 1, 2, 5, and 10. Then construct a trace table showing the recursive calls made to compute
fib(5). - Write a forward declaration for
powin a file calledmy_program.cand definepowin a separate file calledmy_pow.c. Compile them separately into object files usinggcc -cand then link them together manually usinggcc my_program.o my_pow.o -o my_program. Verify the output matches Listing 14. - The lecture shows that
pow(2, pow(3, 2))can be used to compute 2^(3^2). Write a C program that evaluates this nested call and prints the result. In what order are the two calls topowevaluated? Verify by adding aprintfstatement insidepowthat prints each time it is called. - Compile Listing 15 with
gcc -O0 -Wno-builtin-declaration-mismatch -fno-omit-frame-pointer -fno-stack-protector -S calling.cand examine the assembly output. Identify which lines correspond to: (a) saving the base pointer, (b) placing arguments into registers, (c) the function call, and (d) reading the return value. Compare your findings to the explanation given in the lecture. - The lecture states that the
retinstruction takes whatever is at the top of the stack and jumps to that memory location. Looking at Listing 16, identify where the return address is placed on the stack and where it is consumed. What instruction places it there implicitly, and why is this necessary for the program to continue executing correctly afterpowreturns?