Hunter Liu's Website

10. Week 6 Tuesday: Revisiting Wordle with Functions

≪ 9. Week 5: Making a Wordle Clone | Table of Contents | 11. Week 6 Thursday: References ≫

Functions and the Box Model

A little while ago, we introduced the box model for C++ as a way to represent the state of the program. What’s important is that it’s one guy (Cecil, Mark, Steve) and a bunch of boxes in a warehouse moving data around.

Let’s imagine that we are the guy working at the warehouse. When creating complicated projects such as last week’s Wordle, the list of instructions we would have to perform can get pretty out of hand. Our life would be much easier if we were promoted to a managerial role, dispatching an army of underpaid independent contractors to do all the hard work with the boxes instead of doing it ourselves. This is the principle behind functions.

Each function or “job” must describe three key components:

  1. A possibly empty list of “input data”, called the parameters. The function make_coffee may require the amount of sweetener, creamer, and type of bean to even be executed.
  2. A set of instructions, called the “function body”. The function make_coffee would have instructions on how to brew the coffee, etc.
  3. A return type — this is what you expect the contractor to give you once the job has been done. The function make_coffee should return a cup of coffee, for instance, but each cup may be different depending on the parameters. Note that this can be void, where you expect the contractor to silently finish their job and never speak with you again (e.g., the job is cleaning a bathroom… there’s nothing to return!)

You can already see this at work in some problems we’ve done! The max function in C++, for instance, takes two integers as parameters, has a mysterious function body that determines the bigger one, and “returns” the larger of the two. You can imagine giving a toddler two numbers, watching it (them?) derp around for a while, and toddling back to you with the bigger number.

To illustrate this analogy, let’s consider the following example:

Problem 1.

Determine the output of the following code.

 1#include <iostream> 
 2#include <string> 
 3
 4using namespace std; 
 5
 6string f(string input) {
 7    string s = ""; 
 8    for(int i = input.length() - 1; i >= 0; i -= 2) {
 9        s += input.at(i); 
10    } 
11
12    return s; 
13} 
14
15int main() {
16    string s1 = "pic10a rox"; 
17    string s2 = "oatmeal sux"; 
18
19    string s3 = f(s1); 
20    cout << s3 << endl; 
21    cout << f(s2) << endl; 
22    return 0; 
23} 

The function f is describing the following job:

  1. The independent contractor receives a string called input. We can model this as giving the contractor a box labelled input and containing a string.
  2. The contractor is to then extract every other character from the string input (think about this!), starting at the end and going backwards, and stores it in the string s.
  3. When they are done, they “return” to us with this string s, which we can then store in a box ourselves or print to the screen!

The program is still the set of instructions in the main function, and the first two lines say to create two string variables s1 and s2 with whatever contents. On line 19, which says string s3 = f(s1);, we first get really lazy and hire a contractor (maybe named Bob) to perform the job f described above. We create a box called input, copy the contents of s1 into it, hand it off to Bob, and tell him to do his job. Bob then diligently extracts every other character and such, producing the string xra1i. He then “returns” to us with this string, allowing us to store xra1i in the string variable s1.

Now Bob’s job is done. All the boxes he used — the string input, the string s, and the int i (in the loop) — are destroyed in the process. Bob is fired and is never seen again.

On line 20, we print the contents of s1, which is xra1i.

On line 21, we need to run job f again, and we hire a new guy (maybe Joe)! We still need to provide him a string called input, but this time we copy the contents of s2 into it. We tell Joe to do his job, and he “returns” to us with the string xsleto. We directly print this string to the screen.

I hope this analogy with manual labour and independent contractors is not too contrived for your taste. I think one benefit is that it allows us to rationalise many of the common mistakes that are made with functions. Let’s consider the following motivating example:

 1// accepts a string as an input. 
 2void has_digit(string s) {
 3    // for each character in the string, 
 4    //      check if that char. is between '0' and '9'. 
 5    //      if yes, return true! 
 6    for(int i = 0; i < s.length(); i++) {
 7        if('0' <= s.at(i) && s.at(i) <= '9') {
 8            return true; 
 9        } 
10    } 
11} 
12
13int main() {
14    cout << "Enter a word: "; 
15    string input; 
16    cin >> input; 
17
18    if(has_digit(input)) {
19        cout << "Your input had a digit in it!" << endl; 
20    } else {
21        cout << "Mathematicians around the globe hate you." 
22             << endl; 
23    } 
24
25    return 0; 
26} 

Here, there are two immediate flaws that appear:

  1. The function has_digit has return type void, i.e. we expect the contractor executing this job to never come back to us. Yet in the function body, there is a command that says to return true;… this produces a build error. We need to change the return type to a bool!
  2. Suppose the string s doesn’t have any digits at all. What will the contractor return to us with? Assuming that the previous fix has been implemented, we’ll be waiting for the contractor to come back with a bool until the end of time… The compiler is smart enough to recognise that this is an issue! We need to ensure that the contractor returns false if they cannot find a digit anywhere.

Functions in Practise

Let’s see how functions are written in practise by revisiting our Wordle clone from last week. Our first iteration of pseudocode was pretty straightforward:

  1. Pick a random five-letter word.
  2. Repeat 6 times:
    • Ask the user for a guess.
    • Verify that the user has guessed a real 5-letter word.
    • Show which letters are incorrect, partially correct, and correct.
    • If all five letters are correct, the user wins!
  3. If the user used all six guesses and hasn’t won, then the user has lost.

Some of these pieces of pseudocode are relatively easy to translate into C++ code directly, such as asking the user for a guess. But other steps, such as picking a random word and validating the guesses, took a substantial amount of effort. We ended up needing a second layer of pseudocode, and the resulting C++ program no longer closely lines up with the above outline very closely. It is not hard to imagine that even more complex programs quickly become unmanageable if we keep everything in one big main function.

Let us now demonstrate the practical benefit of functions by reorganising our Wordle code to incorporate them. The rule of thumb I’ll follow is, whenever there’s a complex verb that isn’t easily coded, I’ll just design a function that accomplishes the job. As such, there are three spots that demand a function:

Let’s begin with picking a random word. I imagine the main program tells a servant, “Fetch me a random word.” We can name the function getRandomWord (we are describing the job). The servant shouldn’t require anything to get a random word, so there should be no parameters for the function. And finally, we expect the servant to come back with a word, i.e. with a string, so that’s our return type. We had already written code for actually getting the random word, so here’s the function put together:

1string getRandomWord() {
2    const string WORD_LIST = getWordList(); 
3    int numWords = WORD_LIST.length() / 6; 
4    int n = (rand() % numWords) + 1; 
5    string word = WORD_LIST.substr(6 * n - 6, 5); 
6    return word; 
7} 

This is essentially copy-pasted from our existing program, but with two changes: first, we had to define WORD_LIST in this function since this function (our servant) doesn’t have access to the main program’s boxes. Second, we had to add the extra command return word so that the random word actually makes it back to the main program!

In the main program, we just replace that chunk of code with

1// 1. get a random word 
2string word = getRandomWord(); 

Notice how clear the code becomes!

Next, let’s write a function that validates the user’s guess. I’ll call it validateWord. This now requires a string parameter containing the user’s guess (I have to tell our servant what word they’re validating), and I expect them to come back with a true or false answer, i.e. it should have a return type of bool.

1bool validateWord(string guess) {
2    const string WORD_LIST = getWordList(); 
3    bool invalid = (guess.length() != 5) || 
4        (WORD_LIST.find(guess) == string::npos); 
5    return !invalid; 
6} 

This returns true if the word is valid and false otherwise. The effect on the length of our code in the main program is negligible, but notice the difference in clarity:

1if(!validateGuess(guess)) {
2    // print an error message 
3    cout << "Invalid guess! Try again. " << endl; 
4
5    // update the for loop counter and repeat this iteration
6    i--; 
7    continue; 
8}

Problem 2.

Design and fill out a function that handles the correct, incorrect, and partially correct letters.