09: Functions

UC Irvine - Fall ‘22 - ICS 45C

Quick list of things I want to talk about:

  • Typed
  • Single return value
  • Pass-by-value
  • Pass-by-reference
    • arrays
  • Overloading

Expanded notes:

Functions are building blocks of large projects. You generally want each function to serve a single small purpose, and then you can combine multiple of them to achieve larger goals.

We have talked a little bit about a specific function, so let’s use that as an example.

The main function

We already have been using one function, main. So let’s take a look at that and use it as an example:

int main() {

  return 0;
}

The first line in this code sinppet is called the function signature. We know the function type (int), the function name (main), and its parameters (none in this case). Then, we open brackets to define what’s inside the function’s body.

At the end of this function, we have a return statement that matches the type. A return ends the function at that spot and gives the value back. We can have multiple returns, but each one should have a single value that matches the required type.

Once we learn how to create our custom-defined types, you’ll be able to return more than one result. However, you’ll end up with multiple things packed into a single variable, so you’ll still be returning a single value!

Types

Functions can take any type we can define. So char/int/string/float are all valid. Once we learn how to define our custom types, those would be valid types as well.

The only caveat here is that your function should always return a value that matches that type.

If you have a function that does not return anything, e.g., it prints a menu and doesn’t do anything else, there is a special type called void, which means a null value. void functions do not need return statements, but you’re able to use empty returns (return;) if you want to terminate the function.

Parameters

Usually, your function will need values to work with. For example, let’s say you want to create a function that finds the max of two ints. To do that, you’d need to receive two values, let’s call them x and y.

int max2(int x, int y) {
  return (x > y) ? x : y;
}

Here we created a function of type int, called max2. Then, inside the parentheses after the function name we defined two parameters.

The first one is an int that we will call x within the scope of this function. The second is also an int, and we will call it y in this function’s scope.

Finally, we use a ternary operator to return the max between x and y.

We already covered the function signature and return, so let’s dive a little deeper into the parameters. There are two ways of receiving parameters, by value, or by reference.

Pass-by-value

This is the most common way of receiving parameters. In the function max2 we defined above, both parameters are passed by value. To add pass-by-value parameters, we add the type and parameter names separated by commas. Like this:

void my_function(int param1, double param2, int param3, char param4) { }

When you receive a parameter by value, you can imagine that we have a separate copy of any values we received. So if you try changing the values of the parameters, they will only change inside the function.

For example, if you run this code:

void dummy_function(int xyz) {
  xyz = 100;
}

int main() {
  int abc = 1;
  cout << abc << '\n';
  dummy_function(abc);
  cout << abc << '\n';
}

You should see this output:

1
1

Because our dummy_function cannot change the value of the abc variable, it can only modify its own local copy (xyz).

Pass-by-reference

When you receive a parameter by reference, you now have direct access to the original value. You can imagine that our local copy is mirrored in the original variable. So if we modify the local one, the “external” one will also change.

To add a parameter by reference, we simply add a single & before the parameter name. Like this:

void my_function2(int &param1, double &param2, int &param3, char &param4) { }

For example, if you run this code:

void dummy_function2(int &xyz) {
  xyz = 100;
}

int main() {
  int abc = 1;
  cout << abc << '\n';
  dummy_function2(abc);
  cout << abc << '\n';
}

You should see this output:

1
100

Since we have a reference to the original value, we can access and modify it. So when we change the value of xyz, the value of abc is also updated.

Using pass-by-reference parameters allows you to return more values from a function, since you can modify an external variable. You will probably see this a lot in larger codebases.

Arrays as parameters

For example, if you run this code:

void print_array(int array[], unsigned int size) {
  for (unsigned int i = 0; i < size; i++) {
    cout << array[i] << ", ";
  }
  cout << '\n';
}

void dummy_function3(int xyz[]) {
  xyz[0] = 100;
}

int main() {
  int nums[]{1, 2, 3, 4, 5};
  print_array(nums, 5);
  dummy_function3(nums);
  print_array(nums, 5);
}

You should see this output:

1, 2, 3, 4, 5,
100, 2, 3, 4, 5,

Note that the original array was modified, even though we didn’t add a &. That happened because arrays are always passed by reference! We will add more details of how this happens once we get to pointers :)

Array size

As a side note, it’s interesting to check that sizeof might not work as expected inside a function.

void check_size(int array[]) {
  cout << sizeof(array);
}

int main() {
  int nums[]{1, 2, 3, 4, 5};
  check_size(nums);
}

If you compile this code using our usual flags, you should get an error:

tests.cpp:13:17: error: sizeof on array function parameter will return size of 'int *' instead of 'int []' [-Werror,-Wsizeof-array-argument]
  cout << sizeof(array);
                ^
tests.cpp:12:21: note: declared here
void check_size(int array[]) {
                    ^
1 error generated.

The message is telling us that sizeof will show a size of an int *, instead of int []. An int [] is an array, but an int * is a pointer. So there’s actually something happening behind the scenes here – which we’ll see in the pointers lecture!

However, just for the sake of completing this example, if we ignore the warning and run the code, I got this output:

8

Going by the way we analyzed this result in the array notes, we wouldn’t expect 5 numbers in this array. So we can’t really assume the length of arrays we receive in functions, so we would usually have an extra parameter that defines the size of the array, like in our print_array function above.

Multi-dimensional arrays

Like we saw above, the function doesn’t know the size of the array that is passed. Since arrays are stored sequentially in memory (like we saw in lecture and will see later when talking about pointers), we might need to tell the function how big it is.

If an array is 1-dimensional, then it’s not a problem, because we only need to know the offset (i.e., index) we want to go to. If an array is at least 2-dimensional, we need to tell the function/compiler the size of the dimensions.

For example, if we have an array char values[4][5], you would need to have your function signature like this:

void my_function(char my_array[4][5]) {
  cout << my_array[1][2];
}

Constant parameters

For all passing types of parameters, you can make them consts. You just need to add the keyword before the parameter types. Like this:

void my_function4(const int a, const double &b, const short c[]) { }

By doing that, you’d get a compiler error in case you modify something you didn’t want to.

Default arguments

If you have some parameters that can have a default value if the user doesn’t provide one, you can initialize them in the function signature.

For example, if we define a function like this:

void my_function5(int a, int b = 0) {
  cout << "a: " << a << ", b: " << b << '\n';
}

Both calls below would output the same thing, because the default value for b is 0.

my_function5(1, 0);
my_function5(1);

General syntax

So, in general, this is the syntax to create a function:

TYPE_0 NAME (TYPE_1 p1, TYPE_2 p2, ..., TYPE_N pn) {
  // body of this function.
  return VALUE_OF_TYPE_0;
}

Note that all types could be different, so an int function can receive a float parameter for example.

Function overloading

Overloading is a term used when we can have multiple definitions for the same thing. But hang on… C++ doesn’t usually like when I redefine something. So what gives?

When you call a function, for example:

dummy_function2(abc);

The compiler tries to find a function called dummy_function2 that takes a single argument, and that argument is of type int. What happens if we try to call dummy_function2(abc, def); instead?

You’ll probably get an error like this:

tests.cpp:24:3: error: no matching function for call to 'dummy_function2'
  dummy_function2(abc, def);
  ^~~~~~~~~~~~~~~
tests.cpp:11:6: note: candidate function not viable: requires single argument 'xyz', but 2 arguments were provided
void dummy_function2(float xyz) {
     ^
1 error generated.

However, you could create a second definition of dummy_function2 that takes two parameters:

void dummy_function2(int &xyz) {
  xyz = 100;
}

void dummy_function2(float &xyz) {
  xyz = 123;
}

void dummy_function2(int &xyz, int &zyx) {
  xyz = 200;
  zyx = 300;
}

int main() {
  int abc = 1, def=2, fed=3;
  float aaa = 1.5;
  cout << aaa << ' ' << abc << ' ' << def << ' ' << fed << '\n';
  dummy_function2(aaa);
  dummy_function2(abc);
  dummy_function2(def, fed);
  cout << aaa << ' ' << abc << ' ' << def << ' ' << fed << '\n';
}

This code works and you should see the following output if you run it:

1.5 1 2 3
123 100 200 300

Since the three signatures are different, the compiler know when we want to use each one. So this is called function overloading. We have many definitions of a single function, we are overloading that name.

(Much) Later in this course, we’ll cover how we can create functions that take variable number and variable types of arguments.

Complete example

Lastly, let’s see how we can put all of this together:

/* function_example.cpp
Author: Caio ([email protected])
Date created: 2022-10-09
Description: Short example showing the structure of a file with custom functions.
*/

#include <iostream>

using namespace std;

int max2(int x, int y) {
  return (x > y) ? x : y;
}

int main() {
  int a, b;
  cout << "Please enter two numbers: ";
  cin >> a >> b;
  cout << "The highest between those two is " << max2(a, b) << '\n';
  return 0;
}

We have 4 parts of interest in this code:

  1. if your code gets longer, you might want to add a comment describing what it does in general and who wrote it;
  2. we have the includes we need;
  3. we have the namespace directives;
  4. we have the custom defined functions;
  5. we have the main function.

Until we start looking at separate compilation, your code should generally look like that. After we learn how to split it up, we’ll make some changes to it.

However, it’s important to note here that main comes at the end here. Why is that? Since main wants to use max2, we need to have max2 created before we try to use it. So by placing main at the bottom, we make sure that all functions we want are defined.

Function documentation

In other languages we have specific ways to annotate functions that provide help to users. For example, in python you might be familiar with docstrings.

Unfortunately there’s nothing built-in in C++ like that. The common way to achieve that is to use external tools, like doxygen. Although we won’t cover that in this course and you won’t be tested on it, if you have time you can explore how to use that!

All that being said, if your functions start getting a little large, it’s interesting to add comments to the top. That way, if someone’s reading your code just to use it, they can infer what it’s doing from the function’s signature and your short description.

For example, here’s a long function from tensorflow that has a pretty nice explanation on top:

// A while loop with a single loop variable looks like this:
//
// (output)
//     ^    +---------------+
//     |    | body subgraph +-------------+
//    Exit  +---------------+             |
//      ^    ^                            |
//      |    |                            |
//      Switch<--------+                  v
//        ^            |             NextIteration
//        |     +------+--------+         |
//        +---->| cond subgraph |         |
//        |     +---------------+         |
//       Merge<---------------------------+
//       ^
//       |
//    Enter
//      ^
//      |
//   (input)
//
// If there are multiple loop variables, each of the control flow ops is
// duplicated for each loop variable.
// TODO(skyewm): link to public version of design doc
Status BuildWhileLoop(const Scope& scope, const std::vector<Output>& inputs,
                      const CondGraphBuilderFn& cond,
                      const BodyGraphBuilderFn& body, const string& frame_name,
                      OutputList* outputs, bool create_while_ctx,
                      Output* cond_output) {
  ...
}

Don’t worry about the types here, they are probably defined within the projecte. Just take a look at the comment before the function signature. You can see that there is not really some specific format, they even have a flowchart in there! However, it does a great job explaining the logic of this function. Even if we don’t want to take a peek inside, or if we don’t understand how they do it, we can tell what this function is doing: it implements a while loop for their framework.

References