Embercode
✕
C++ Functions and ScopeLesson 14 of 17

40 min read

Refactoring a long main

Why this matters

Many beginner programs put everything inside main. Reading input, doing the arithmetic, and printing the results all happen in one long list of lines. This works while the program is short. Once it passes about forty lines, it becomes hard to read. To change one part, you must scroll past everything else and hold many variables in your head at once.

Refactoring means changing the structure of code without changing what it does. One common way is to move a group of lines into a function with a name. The name tells the reader what the group does, and main becomes a short list of those names. A reader can then see the outline of the program in a few lines.

This lesson takes one long main that prints a shop receipt and splits it into named functions in three steps. After each step, the program runs and its output is compared with the output from before the change. The lesson does not explain how to pass values into functions or return them, because the earlier functions lessons cover that. It only shows how to choose the cut points and how to check each cut.

The starting program

The receipt program below has one main of about thirty lines. It stores three items as three lists, one entry per item. Each item has a name, a price in cents, and a quantity. The program prints one line per item, works out a 10 percent discount for large orders, adds 8 percent tax, and prints the totals.

C++ · editable

Press Run. The output is the reference that every later step must match:

RECEIPT
-------
Notebook x2 = 500
Pen x5 = 495
Ruler x1 = 120
-------
Subtotal: 1115
Discount: 111
Tax: 80
Total: 1084
Amounts are in cents.

Check the arithmetic by hand so you know what the numbers mean. Two notebooks at 250 cents cost 500. Five pens at 99 cents cost 495. One ruler at 120 cents costs 120. The subtotal is 1115. The subtotal is at least 1000, so the discount is one tenth of it, which is 111 with the fraction dropped. The taxable amount is 1115 minus 111, which is 1004. The tax is 8 percent of 1004. The expression taxable * 8 / 100 gives 80, because integer division drops the fraction. The total is 1004 plus 80, which is 1084.

The program uses whole numbers of cents on purpose. Money stored as floating-point numbers (numbers with a decimal point, stored in an approximate form) can pick up tiny rounding errors. The floating-point lesson in the variables and data types module covers this.

The method: small steps and a saved output

A refactor can go wrong in many small ways. A variable can be passed to the wrong function. A line can be left out. A calculation can be moved to a place where a different value is available. You will not always notice these by reading the code, so use a method that catches them.

The method has four rules.

  1. Save the output of the working program to a file before any change.
  2. Make one small change, such as moving one group of lines into a function.
  3. Compile with warnings turned on, run the program, and save its new output.
  4. Compare the new output with the saved output. If they differ, undo the change and find out why before going on.

The commands look like this. The -Wall flag turns on warnings. The -std=c++20 flag selects the C++ version. The > symbol sends the program's output into a file instead of the screen.

g++ -std=c++20 -Wall -o receipt receipt.cpp
./receipt > expected.txt

After each change, run the same two commands with a new name and compare the results:

g++ -std=c++20 -Wall -o receipt_step1 receipt_step1.cpp
./receipt_step1 > step1.txt
diff expected.txt step1.txt

The diff command prints nothing when the two files are the same. If it prints nothing, the change kept the output the same. If it prints lines, each line shows where the two files differ. This check is the reason the steps are small. When a diff appears, only one change could have caused it.

Choosing where to cut

Before writing any function, read the long main and mark its groups. Three signs help with the choice.

  • Blank lines and headings in the output. The starting program has blank lines between its groups: setting up the data, adding up the items, working out the totals, and printing. Each group is a candidate for one function.
  • Which variables a group uses. A group that reads a few inputs and produces one result, such as the subtotal loop, can become a function with those inputs as parameters (the names in a function's first line that receive the values). A group that reads many variables and changes several of them is harder to cut. Leave it for later, after the simple cuts are done.
  • The length of the parameter list. If a function needs six or seven inputs, the cut may be in the wrong place, or some of those inputs belong together. Stop and check the cut before adding more parameters.

The steps below use these signs in order: first the calculation with clear inputs, then the small rules, and last the printing, which reads the most variables.

Step 1: pull out the subtotal

The first cut is the loop that adds up the subtotal. It is a calculation with no printing in it, so it is a clear candidate for a function. Name the function after what it returns. Here the function returns the subtotal, so it is called compute_subtotal.

int compute_subtotal(const vector<int>& prices, const vector<int>& quantities) {
    int subtotal = 0;
    for (size_t i = 0; i < prices.size(); i = i + 1) {
        subtotal = subtotal + prices[i] * quantities[i];
    }
    return subtotal;
}

The function needs the two lists of numbers, so they are parameters. The const and & in the parameter list mean that the function reads the list without copying it and cannot change it. The lessons on parameters explain both words.

In main, the old loop is replaced by a call. The printing loop still exists, but it no longer adds to the subtotal. It prints the line total directly. Leaving the old adding code in main as well would run the same work twice, and a reader could not tell which copy the program uses.

C++ · editable

Run it. The output is the same as the reference. With the saved file, diff expected.txt step1.txt prints nothing, so step 1 is kept.

The check is worth doing even for a change that looks trivial. The output is the only evidence that the function returns what the old loop produced.

Step 2: pull out the discount and the tax

The discount and the tax are each a small rule with one input and one output. Each one can be its own function. The discount function takes the subtotal and returns the discount. The tax function takes the taxable amount and returns the tax.

int compute_discount(int subtotal) {
    if (subtotal >= 1000) {
        return subtotal / 10;
    }
    return 0;
}
 
int compute_tax(int taxable) {
    return taxable * 8 / 100;
}

Look at the discount function. The if in main set discount to 0 and only changed it when the rule applied. The function has the same behaviour by returning 0 at the end. Keeping the same behaviour is the whole point of the step.

In main, the lines that used to compute the discount and the tax now call the new functions. The order of the calls matches the old order, so each value is computed from the same input as before.

C++ · editable

Run it and compare. The output is still the same. This step is also where a mistake is easiest to make. Here is the same step with one word changed: the tax is now worked out from the subtotal, not from the taxable amount.

int tax = compute_tax(subtotal);

The program still compiles and runs without any message. Only the output shows the problem. The diff command shows exactly which lines changed:

diff expected.txt step2_bug.txt
9,10c9,10
< Tax: 80
< Total: 1084
---
> Tax: 89
> Total: 1093

Read the diff output this way. The first line says that lines 9 and 10 of the first file were changed into lines 9 and 10 of the second file. The lines starting with < come from the first file, the saved output of the working program. The lines starting with > come from the new program. The tax is 89 instead of 80, because the tax was taken on 1115 instead of 1004. The total moved with it.

Without the saved file, this bug could have stayed in the program for weeks. The diff points to the two lines that differ, and those lines point to the one line that changed. Change compute_tax(subtotal) back to compute_tax(taxable), run the diff again, and it prints nothing.

Step 3: pull out the printing

The last cut moves all the printing into functions. The printing has two parts: the item lines and the totals block. Each part gets its own function. The item function needs the three lists. The totals function needs the four numbers it prints.

void print_items(const vector<string>& names, const vector<int>& prices, const vector<int>& quantities) {
    cout << "RECEIPT" << endl;
    cout << "-------" << endl;
    for (size_t i = 0; i < names.size(); i = i + 1) {
        cout << names[i] << " x" << quantities[i] << " = " << prices[i] * quantities[i] << endl;
    }
}
 
void print_totals(int subtotal, int discount, int tax, int total) {
    cout << "-------" << endl;
    cout << "Subtotal: " << subtotal << endl;
    cout << "Discount: " << discount << endl;
    cout << "Tax: " << tax << endl;
    cout << "Total: " << total << endl;
    cout << "Amounts are in cents." << endl;
}

The functions are named with a verb and a noun, print_items and print_totals. A reader sees the name in main and knows what happens without opening the function. The void return type means the function prints and returns nothing.

After this step, main reads like a summary of the program. It gets the data, works out the numbers, and prints them.

C++ · editable

Run it, save the output as step3.txt, and compare with the reference. The diff prints nothing. The program now has the same output as the long version, and its main is about fifteen lines.

The compare also protects the order of the lines. The header lines come from print_items, and the dashes that separate the item lines from the totals come from print_totals. If the separator had been printed in the wrong function, the diff would show lines moved up or down.

Checking a second input

A saved output proves only that the program works for the data it was given. The receipt has a discount branch that runs only when the subtotal is 1000 or more. The reference run takes that branch. A change that breaks the other branch would not show up in the diff.

To test the other path, change the quantities to one of each item. The subtotal becomes 469, which is below the discount limit. This is the output of the step 3 program with that change:

RECEIPT
-------
Notebook x1 = 250
Pen x1 = 99
Ruler x1 = 120
-------
Subtotal: 469
Discount: 0
Tax: 37
Total: 506
Amounts are in cents.

The discount is 0 here, and the tax is 469 times 8 divided by 100, which is 37 with the fraction dropped. The total is 506. For a refactor, keep one saved output for each input you care about, and compare each one after each step.

Common mistakes

The compiler catches some refactoring mistakes, and each one shows a message that names the problem. The programs below each contain one mistake. The messages are the real output of g++ 15 run with -std=c++20 -Wall.

A function uses a name from main

The helper below reads names, but names is a local variable of main and is not visible inside another function.

#include <iostream>
#include <string>
#include <vector>
using namespace std;
 
void print_items(const vector<int>& prices, const vector<int>& quantities) {
    for (size_t i = 0; i < names.size(); i = i + 1) {
        cout << names[i] << " " << prices[i] * quantities[i] << endl;
    }
}
 
int main() {
    vector<int> prices = {250};
    vector<int> quantities = {2};
    print_items(prices, quantities);
    return 0;
}

The compiler reports:

e_names.cpp: In function 'void print_items(const std::vector<int>&, const std::vector<int>&)':
e_names.cpp:7:28: error: 'names' was not declared in this scope
    7 |     for (size_t i = 0; i < names.size(); i = i + 1) {
      |                            ^~~~~

The fix is to pass names in as a parameter, the same way prices is passed. Every value a function needs must come through its parameter list, not from a variable that happens to be nearby in another function.

A function is called before it is declared

The compiler reads the file from top to bottom. A function must be declared above the line that calls it. This program defines compute_tax after main:

#include <iostream>
using namespace std;
 
int main() {
    int tax = compute_tax(1004);
    cout << tax << endl;
    return 0;
}
 
int compute_tax(int taxable) {
    return taxable * 8 / 100;
}

The compiler reports:

e_declared.cpp: In function 'int main()':
e_declared.cpp:5:15: error: 'compute_tax' was not declared in this scope
    5 |     int tax = compute_tax(1004);
      |               ^~~~~~~~~~~

The fix is to move the function definition above main. A common alternative is to write a declaration above main, such as int compute_tax(int taxable);, and keep the body where it is. Moving the definition is simpler for a beginner.

A call leaves out an argument

When a function is changed to take a new parameter, every call must be updated. The message names the function and its parameters.

#include <iostream>
using namespace std;
 
int compute_tax(int taxable) {
    return taxable * 8 / 100;
}
 
int main() {
    cout << compute_tax() << endl;
    return 0;
}
e_args.cpp: In function 'int main()':
e_args.cpp:9:24: error: too few arguments to function 'int compute_tax(int)'
    9 |     cout << compute_tax() << endl;
      |             ~~~~~~~~~~~^~
e_args.cpp:4:5: note: declared here
    4 | int compute_tax(int taxable) {
      |     ^~~~~~~~~~~

The note at the bottom points to the definition, so you can compare the parameter list with the call. A helper that takes two lists, called with one list, produces the same kind of message.

A function has no return statement

A function that returns a value must return it on every path. In this version, compute_tax calculates the tax and then forgets to return it:

#include <iostream>
using namespace std;
 
int compute_tax(int taxable) {
    int tax = taxable * 8 / 100;
}
 
int main() {
    cout << compute_tax(1004) << endl;
    return 0;
}

The compiler does not stop with an error. It prints warnings:

e_noreturn.cpp: In function 'int compute_tax(int)':
e_noreturn.cpp:5:9: warning: unused variable 'tax' [-Wunused-variable]
    5 |     int tax = taxable * 8 / 100;
      |         ^~~
e_noreturn.cpp:6:1: warning: no return statement in function returning non-void [-Wreturn-type]
    6 | }
      | ^

The first warning says that tax is computed and never used. The second says the function reaches its closing brace without returning a value, so the caller gets an unpredictable number. A program with this bug may print a wrong total, and the warning is the only clue. Compile with -Wall and read every warning after each step. Warnings are not optional in a refactor.

Leaving the old code in place

The most common refactoring mistake is not a compile error. A programmer adds the new function, calls it, and forgets to delete the old lines. The program still works, because the old lines compute the same value, but the work is done twice and the two copies can drift apart. Later, someone edits the function and the old copy in main stays the same, and the program prints a result from the old copy. Delete the old lines in the same step that adds the new function.

Practice

Each exercise is followed by its answer. Try each one before reading the answer.

Exercise 1. Why is the saved output file made from the original program, before any change, and not after each step?

Show the answer

The saved file is the record of what the program did before the refactor started. Each step is compared with that record, so a difference always means the step changed the behaviour. If the file were replaced after each step, a wrong change would become the new reference, and the diff would show nothing.

Exercise 2. Write a function print_header that prints the two lines RECEIPT and -------, and show the call that replaces the same two lines in main.

Show the answer

void print_header() {
    cout << "RECEIPT" << endl;
    cout << "-------" << endl;
}

In main, replace the two cout lines with the call print_header();. The function has no parameters, because the two lines never change. The void return type means it returns nothing.

Exercise 3. The function below is meant to return the tax on a taxable amount. Find the problem and fix it. The compiler gives two warnings. Explain each one.

int compute_tax(int taxable) {
    int tax = taxable * 8 / 100;
}

Show the answer

The function never returns tax. The warning "no return statement in function returning non-void" points at the missing return. The warning "unused variable 'tax'" is a consequence: tax is computed but never used. The fix is one line:

int compute_tax(int taxable) {
    int tax = taxable * 8 / 100;
    return tax;
}

Or, shorter: return taxable * 8 / 100;. After the fix, both warnings go away.

Exercise 4. A helper called print_items prints the item lines, but it fails with the message 'names' was not declared in this scope. Explain what the message means and how to fix it without moving names into the helper.

Show the answer

The helper uses the name names, but names is a local variable of main, and no other function can see it. The fix is to add names to the helper's parameter list, as const vector<string>& names, and to pass it in the call from main, as print_items(names, prices, quantities). The helper then gets the value through its parameter, which is the normal way for one function to see data from another.

Exercise 5. Change the quantities in the step 3 program to {1, 1, 1}. Predict the subtotal, the discount, the tax and the total before you run it. Then run it and compare.

Show the answer

The subtotal is 250 plus 99 plus 120, which is 469. The discount is 0 because 469 is below 1000. The tax is 469 times 8 divided by 100, which is 37. The total is 469 plus 37, which is 506. The program prints:

RECEIPT
-------
Notebook x1 = 250
Pen x1 = 99
Ruler x1 = 120
-------
Subtotal: 469
Discount: 0
Tax: 37
Total: 506
Amounts are in cents.

Summary

  • Refactoring changes the structure of a program without changing its output, so the output is the test.
  • Save the output of the working program first, then compare the output of each small step with diff.
  • Cut the long main at points where a group of lines does one job, and give the function a name that says what it does.
  • Delete the old lines in the same step that adds the new function, so the work is not done twice.
  • Read every compiler warning after each step, because a missing return compiles and only shows as a wrong answer.
Check yourself: Refactoring (6 questions) →Practice this topic: coding problems for C++ Functions and Scope →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Static locals and counters