Functions
Similarly to how fundamental types are building blocks of program data, functions are building blocks of program behavior.
Functions are also closely related to the craft aspect of programming. The functions that are written by experienced programmers are succinct, simple, and clear. This goes both ways: The mere act of trying to identify and write smaller building blocks of a program makes for a better programmer.
We have covered basic statements and expressions in previous chapters. Although there will be many more that we will see in later chapters, what we have seen so far are commonly-used features of D. Still, they are not sufficient on their own to write large programs. The programs that we have written so far have all been very short, each demonstrating just a simple feature of the language. Trying to write a program with any level of complexity without functions would be very difficult and prone to bugs.
This chapter covers only the basic features of functions. We will see more about functions later in the following chapters:
Functions are features that put statements and expressions together as units of program execution. Such statements and expressions altogether are given a name that describes what they collectively achieve. They can then be called (or executed) by using that name.
The concept of giving names to a group of steps is common in our daily lives. For example, the act of cooking an omelet can be described in some level of detail by the following steps:
- get a pan
- get butter
- get an egg
- turn on the stove
- put the pan on the fire
- put butter into the pan when it is hot
- put the egg into butter when it is melted
- remove the pan from the fire when the egg is cooked
- turn off the stove
Since that much detail is obviously excessive, steps that are related together would be combined under a single name:
- make preparations (get the pan, butter, and the egg)
- turn on the stove
- cook the egg (put the pan on the fire, etc.)
- turn off the stove
Going further, there can be a single name for all of the steps:
- make a one-egg omelet (all of the steps)
Functions are based on the same concept: steps that can collectively be named as a whole are put together to form a function. As an example, let's start with the following lines of code that achieve the task of printing a menu:
writeln(" 0 Exit"); writeln(" 1 Add"); writeln(" 2 Subtract"); writeln(" 3 Multiply"); writeln(" 4 Divide");
Since it would make sense to name those combined lines as printMenu
, they can be put together to form a function by using the following syntax:
void printMenu() { writeln(" 0 Exit"); writeln(" 1 Add"); writeln(" 2 Subtract"); writeln(" 3 Multiply"); writeln(" 4 Divide"); }
The contents of that function can now be executed from within main()
simply by using its name:
void main() { printMenu(); // ... }
It may be obvious from the similarities of the definitions of printMenu()
and main()
that main()
is a function as well. The execution of a D program starts with the function named main()
and branches out to other functions from there.
Parameters
Some of the powers of functions come from the fact that their behaviors are adjustable through parameters.
Let's continue with the omelet example by modifying it to make an omelet of five eggs instead of always one. The steps would exactly be the same, the only difference being the number of eggs to use. We can change the more general description above accordingly:
- make preparations (get the pan, butter, and five eggs)
- turn on the stove
- cook the eggs (put the pan on the fire, etc.)
- turn off the stove
Likewise, the most general single step would become the following:
- make a five-egg omelet (all of the steps)
This time there is an additional information that concerns some of the steps: "get five eggs", "cook the eggs", and "make a five-egg omelet".
The behaviors of functions can be adjusted similarly to the omelet example. The information that functions use to adjust their behavior are called parameters. Parameters are specified in a comma separated function parameter list. The parameter list rests inside of the parentheses that comes after the name of the function.
The printMenu()
function above was defined with an empty parameter list because that function always printed the same menu. Let's assume that sometimes the menu will need to be printed differently in different contexts. For example, it may make more sense to print the first entry as "Return" instead of "Exit" depending on the part of the program that is being executed at that time.
In such a case, the first entry of the menu can be parameterized by having been defined in the parameter list. The function then uses the value of that parameter instead of the literal "Exit"
:
void printMenu(string firstEntry) { writeln(" 0 ", firstEntry); writeln(" 1 Add"); writeln(" 2 Subtract"); writeln(" 3 Multiply"); writeln(" 4 Divide"); }
Notice that since the information that the firstEntry
parameter conveys is a piece of text, its type has been specified as string
in the parameter list. This function can now be called with different parameter values to print menus having different first entries. All that needs to be done is to use the appropriate string
values depending on where the function is being called from:
// At some place in the program: printMenu("Exit"); // ... // At some other place in the program: printMenu("Return");
Note: When you write and use your own functions with parameters of type string
you may encounter compilation errors. As written, printMenu()
above cannot be called with parameter values of type char[]
. For example, the following code would cause a compilation error:
char[] anEntry; anEntry ~= "Take square root"; printMenu(anEntry); // ← compilation ERROR
On the other hand, if printMenu()
were defined to take its parameter as char[]
, then it could not be called with string
s like "Exit"
. This is related to the concept of immutability and the immutable
keyword, both of which will be covered in the next chapter.
Let's continue with the menu function and assume that it is not appropriate to always start the menu selection numbers with zero. In that case the starting number can also be passed to the function as its second parameter. The parameters of the function must be separated by commas:
void printMenu(string firstEntry, int firstNumber) { writeln(' ', firstNumber + 0, ' ', firstEntry); writeln(' ', firstNumber + 1, " Add"); writeln(' ', firstNumber + 2, " Subtract"); writeln(' ', firstNumber + 3, " Multiply"); writeln(' ', firstNumber + 4, " Divide"); }
It is now possible to tell the function what number to start from:
printMenu("Return", 1);
Calling a function
Starting a function so that it achieves its task is called calling a function. The function call syntax is the following:
function_name(parameter_values)
The actual parameter values that are passed to functions are called function arguments. Although the terms parameter and argument are sometimes used interchangeably in the literature, they signify different concepts.
The arguments are matched to the parameters one by one in the order that the parameters are defined. For example, the last call of printMenu()
above uses the arguments "Return"
and 1
, which correspond to the parameters firstEntry
and firstNumber
, respectively.
The type of each argument must match the type of the corresponding parameter.
Doing work
In previous chapters, we have defined expressions as entities that do work. Function calls are expressions as well: they do some work. Doing work means producing a value or having a side effect:
- Producing a value: Some operations only produce values. For example, a function that adds numbers would be producing the result of that addition. As another example, a function that makes a
Student
object by using the student's name and address would be producing aStudent
object. -
Having side effects: Side effects are any change in the state of the program or its environment. Some operations have only side effects. An example is how the
printMenu()
function above changesstdout
by printing to it. As another example, a function that adds aStudent
object to a student container would also have a side effect: it would be causing the container to grow.In summary, operations that cause a change in the state of the program have side effects.
- Having side effects and producing a value: Some operations do both. For example, a function that reads two values from
stdin
and returns their sum would be having side effects due to changing the state ofstdin
and also producing the sum of the two values. - No operation: Although every function is designed as one of the three categories above, depending on certain conditions at compile time or at run time, some functions end up doing no work at all.
The return value
The value that a function produces as a result of its work is called its return value. This term comes from the observation that once the program execution branches into a function, it eventually returns back to where the function has been called. Functions get called and they return values.
Just like any other value, return values have types. The type of the return value is specified right before the name of the function, at the point where the function is defined. For example, a function that adds two values of type int
and returns their sum also as an int
would be defined as follows:
int add(int first, int second) { // ... the actual work of the function ... }
The value that a function returns takes the place of the function call itself. For example, assuming that the function call add(5, 7)
produces the value 12
, then the following two lines would be equivalent:
writeln("Result: ", add(5, 7)); writeln("Result: ", 12);
In the first line above, the add()
function is called with the arguments 5
and 7
before writeln()
gets called. The value 12
that the function returns is in turn passed to writeln()
as its second argument.
This allows passing the return values of functions to other functions to form complex expressions:
writeln("Result: ", add(5, divide(100, studentCount())));
In the line above, the return value of studentCount()
is passed to divide()
as its second argument, the return value of divide()
is passed to add()
as its second argument, and eventually the return value of add()
is passed to writeln()
as its second argument.
The return
statement
The return value of a function is specified by the return
keyword:
int add(int first, int second) { int result = first + second; return result; }
A function produces its return value by taking advantage of statements, expressions, and potentially by calling other functions. The function would then return that value by the return
keyword, at which point the execution of the function ends.
It is possible to have more than one return
statement in a function. The value of the first return
statement that gets executed determines the return value of the function for a particular call:
int complexCalculation(int aParameter, int anotherParameter) { if (aParameter == anotherParameter) { return 0; } return aParameter * anotherParameter; }
The function above returns 0
when the two parameters are equal, and the product of their values when they are different.
void
functions
The return types of functions that do not produce values are specified as void
. We have seen this many times with the main()
function so far, as well as the printMenu()
function above. Since they do not return any value to the caller, their return types have been defined as void
. (Note: main()
can also be defined as returning int
. We will see this in a later chapter.)
The name of the function
The name of a function must be chosen to communicate the purpose of the function clearly. For example, the names add
and printMenu
were appropriate because their purposes were to add two values, and to print a menu, respectively.
A common guideline for function names is that they contain a verb like add or print. According to this guideline names like addition()
and menu()
would be less than ideal.
However, it is acceptable to name functions simply as nouns if those functions do not have any side effects. For example, a function that returns the current temperature can be named as currentTemperature()
instead of getCurrentTemperature()
.
Coming up with names that are clear, short, and consistent is part of the subtle art of programming.
Code quality through functions
Functions can improve the quality of code. Smaller functions with fewer responsibilities lead to programs that are easier to maintain.
Code duplication is harmful
One of the aspects that is highly detrimental to program quality is code duplication. Code duplication occurs when there is more than one piece of code in the program that performs the same task.
Although this sometimes happens by copying lines of code around, it may also happen incidentally when writing separate pieces of code.
One of the problems with pieces of code that duplicate essentially the same functionality is that they present multiple chances for bugs to crop up. When such bugs do occur and we need to fix them, it can be hard to make sure that we have fixed all places where we introduced the problem, as they may be spread around. Conversely, when the code appears in only one place in the program, then we only need to fix it at that one place to get rid of the bug once and for all.
As I mentioned above, functions are closely related to the craft aspect of programming. Experienced programmers are always on the lookout for code duplication. They continually try to identify commonalities in code and move common pieces of code to separate functions (or to common structs, classes, templates, etc., as we will see in later chapters).
Let's start with a program that contains some code duplication. Let's see how that duplication can be removed by moving code into functions (i.e. by refactoring the code). The following program reads numbers from the input and prints them first in the order that they have arrived and then in numerical order:
import std.stdio; import std.algorithm; void main() { int[] numbers; int count; write("How many numbers are you going to enter? "); readf(" %s", &count); // Read the numbers foreach (i; 0 .. count) { int number; write("Number ", i, "? "); readf(" %s", &number); numbers ~= number; } // Print the numbers writeln("Before sorting:"); foreach (i, number; numbers) { writefln("%3d:%5d", i, number); } sort(numbers); // Print the numbers writeln("After sorting:"); foreach (i, number; numbers) { writefln("%3d:%5d", i, number); } }
Some of the duplicated lines of code are obvious in that program. The last two foreach
loops that are used for printing the numbers are exactly the same. Defining a function that might appropriately be named as print()
would remove that duplication. The function could take a slice as a parameter and print it:
void print(int[] slice) { foreach (i, element; slice) { writefln("%3s:%5s", i, element); } }
Notice that the parameter is now referred to using the more general name slice
instead of original and more specific name numbers
. The reason for that is the fact that the function would not know what the elements of the slice would specifically represent. That can only be known at the place where the function has been called from. The elements may be student IDs, parts of a password, etc. Since that cannot be known in the print()
function, general names like slice
and element
are used in its implementation.
The new function can be called from the two places where the slice needs to be printed:
import std.stdio; import std.algorithm; void print(int[] slice) { foreach (i, element; slice) { writefln("%3s:%5s", i, element); } } void main() { int[] numbers; int count; write("How many numbers are you going to enter? "); readf(" %s", &count); // Read the numbers foreach (i; 0 .. count) { int number; write("Number ", i, "? "); readf(" %s", &number); numbers ~= number; } // Print the numbers writeln("Before sorting:"); print(numbers); sort(numbers); // Print the numbers writeln("After sorting:"); print(numbers); }
There is more to do. Notice that there is always a title line printed right before printing the elements of the slice. Although the title is different, the task is the same. If printing the title can be seen as a part of printing the slice, the title too can be passed as a parameter. Here are the new changes:
void print(string title, int[] slice) { writeln(title, ":"); foreach (i, element; slice) { writefln("%3s:%5s", i, element); } } // ... // Print the numbers print("Before sorting", numbers); // ... // Print the numbers print("After sorting", numbers);
This step has the added benefit of obviating the comments that appear right before the two print()
calls. Since the name of the function already clearly communicates what it does, those comments are unnecessary:
print("Before sorting", numbers); sort(numbers); print("After sorting", numbers);
Although subtle, there is more code duplication in this program: The values of count
and number
are read in exactly the same way. The only difference is the message that is printed to the user and the name of the variable:
int count; write("How many numbers are you going to enter? "); readf(" %s", &count); // ... int number; write("Number ", i, "? "); readf(" %s", &number);
The code would become even better if it took advantage of a new function that might be named appropriately as readInt()
. The new function can take the message as a parameter, print that message, read an int
from the input, and return that int
:
int readInt(string message) { int result; write(message, "? "); readf(" %s", &result); return result; }
count
can now be initialized directly by the return value of a call to this new function:
int count = readInt("How many numbers are you going to enter");
number
cannot be initialized in as straightforward a way because the loop counter i
happens to be a part of the message that is displayed when reading number
. This can be overcome by taking advantage of format
:
import std.string; // ... int number = readInt(format("Number %s", i));
Further, since number
is used in only one place in the foreach
loop, its definition can be eliminated altogether and the return value of readInt()
can directly be used in its place:
foreach (i; 0 .. count) { numbers ~= readInt(format("Number %s", i)); }
Let's make a final modification to this program by moving the lines that read the numbers to a separate function. This would also eliminate the need for the "Read the numbers" comment because the name of the new function would already carry that information.
The new readNumbers()
function does not need any parameter to complete its task. It reads some numbers and returns them as a slice. The following is the final version of the program:
import std.stdio; import std.string; import std.algorithm; void print(string title, int[] slice) { writeln(title, ":"); foreach (i, element; slice) { writefln("%3s:%5s", i, element); } } int readInt(string message) { int result; write(message, "? "); readf(" %s", &result); return result; } int[] readNumbers() { int[] result; int count = readInt("How many numbers are you going to enter"); foreach (i; 0 .. count) { result ~= readInt(format("Number %s", i)); } return result; } void main() { int[] numbers = readNumbers(); print("Before sorting", numbers); sort(numbers); print("After sorting", numbers); }
Compare this version of the program to the first one. The major steps of the program are very clear in the main()
function of the new program. In contrast, the main()
function of the first program had to be carefully examined to understand the purpose of that program.
Although the total numbers of nontrivial lines of the two versions of the program ended up being equal in this example, functions make programs shorter in general. This effect is not apparent in this simple program. For example, before the readInt()
function has been defined, reading an int
from the input involved three lines of code. After the definition of readInt()
, the same goal is achieved by a single line of code. Further, the definition of readInt()
allowed removing the definition of the variable number
altogether.
Commented lines of code as functions
Sometimes the need to write a comment to describe the purpose of a group of lines of code is an indication that those lines could better be moved to a newly defined function. If the name of the function is descriptive enough then there will be no need for the comment either.
The three commented groups of lines of the first version of the program have been used for defining new functions that achieved the same tasks.
Another important benefit of removing comment lines is that comments tend to become outdated as the code gets modified over time. When updating code, programmers sometimes forget to update associated comments thus these comments become either useless or, even worse, misleading. For that reason, it is beneficial to try to write programs without the need for comments.
Exercises
- Modify the
printMenu()
function to take the entire set of menu items as a parameter. For example, the menu items can be passed to the function as in the following code:string[] items = [ "Black", "Red", "Green", "Blue", "White" ]; printMenu(items, 1);
Have the program produce the following output:
1 Black 2 Red 3 Green 4 Blue 5 White
- The following program uses a two dimensional array as a canvas. Start with that program and improve it by adding more functionality to it:
import std.stdio; enum totalLines = 20; enum totalColumns = 60; /* The 'alias' in the next line makes 'Line' an alias of * dchar[totalColumns]. Every 'Line' that is used in the rest * of the program will mean dchar[totalColumns] from this * point on. * * Also note that 'Line' is a fixed-length array. */ alias Line = dchar[totalColumns]; /* A dynamic array of Lines is being aliased as 'Canvas'. */ alias Canvas = Line[]; /* Prints the canvas line by line. */ void print(Canvas canvas) { foreach (line; canvas) { writeln(line); } } /* Places a dot at the specified location on the canvas. In a * sense, "paints" the canvas. */ void putDot(Canvas canvas, int line, int column) { canvas[line][column] = '#'; } /* Draws a vertical line of the specified length from the * specified position. */ void drawVerticalLine(Canvas canvas, int line, int column, int length) { foreach (lineToPaint; line .. line + length) { putDot(canvas, lineToPaint, column); } } void main() { Line emptyLine = '.'; /* An empty canvas */ Canvas canvas; /* Constructing the canvas by adding empty lines */ foreach (i; 0 .. totalLines) { canvas ~= emptyLine; } /* Using the canvas */ putDot(canvas, 7, 30); drawVerticalLine(canvas, 5, 10, 4); print(canvas); }