Embercode
✕
C++ ConditionalsLesson 11 of 14

40 min read

Decision tables

Why this matters

Many programs make a decision from several facts at once. A shipping fee depends on whether the buyer is a member and how big the order is. A refund depends on whether the event was cancelled, how long ago the ticket was bought, and whether the ticket was used. When the rules get longer, it is easy to write an if chain that misses a case, puts two rules in the wrong order, or uses && where || was meant. Nobody notices until a customer gets the wrong answer.

A decision table is a way to stop that from happening. You write every combination of the facts in a table. Each row has one value for each fact, and an outcome. Once the table is complete, you write the code with one branch for each row. Each branch in the code can be checked against one row in the table. This lesson shows how to build the table, turn it into code, and check it.

This lesson uses if, else if and else with bool values. You do not need the switch statement for this method.

The idea in plain words

A condition is a question with a yes or no answer, such as "is the buyer a member?". A decision table lists the conditions as columns. The outcome, which is the answer the program should give, is the last column. Each row is one combination of answers.

For two conditions there are four combinations. For three conditions there are eight. In general, with n conditions there are 2 to the power of n rows. The count doubles with every new condition, so a table with five conditions has 32 rows. This is one reason to keep the number of conditions small. A table with more rows is still correct, but it is slower to check by hand.

The method has three steps:

  1. List the conditions, and write each one as a yes or no question.
  2. Fill in every combination of yes and no, then write the outcome for each row.
  3. Write one branch in the code for each row, in the same order as the table.

The third step is the important one. Each branch in the code has a comment that names its row, so a reader can match the code to the table by eye.

Example 1: shipping fee with two conditions

A shop charges a shipping fee. Members pay less than non-members. Orders over 50 units go free for everyone. The two conditions are "is a member?" and "is the order over 50?". With two conditions there are four rows.

MemberOrder over 50Fee
yesyes0
yesno2
noyes0
nono5

Notice that every combination is in the table, including the ones that seem unlikely. The table does not decide what is likely. It only lists what the program can be asked.

Now write one branch for each row. The inputs are read as bool values. A bool holds true or false. The program reads 1 for true and 0 for false, which is why the test runs below use those numbers.

#include <iostream>
using namespace std;
 
int main() {
    bool member;
    bool over_fifty;
    cin >> member >> over_fifty;
 
    int fee;
    if (member && over_fifty) {          // row 1: member, order over 50
        fee = 0;
    } else if (member && !over_fifty) {  // row 2: member, order 50 or less
        fee = 2;
    } else if (!member && over_fifty) {  // row 3: not a member, order over 50
        fee = 0;
    } else {                             // row 4: not a member, order 50 or less
        fee = 5;
    }
    cout << "fee: " << fee << endl;
    return 0;
}

The ! symbol means "not". The expression !over_fifty is true when the order is 50 or less. Each condition in the code matches one column of the table. The last branch is an else, which covers row 4. An else is used for the last row because nothing else is left. If row 4 were written as else if (!member && !over_fifty), the code would still be right, but a reader would have to check by hand that the rows cover every case.

Compile with warnings turned on and run the four rows:

$ g++ -std=c++20 -Wall -Wextra -pedantic -o shipping shipping.cpp
$ echo 1 1 | ./shipping
fee: 0
$ echo 1 0 | ./shipping
fee: 2
$ echo 0 1 | ./shipping
fee: 0
$ echo 0 0 | ./shipping
fee: 5

Each run prints the fee from the matching row. You can check the program against the table in a few seconds, which is the reason for building the table first.

Example 2: refunds with three conditions

A ticket refund depends on three facts: whether the event was cancelled by the organiser, whether the purchase was made within the last 7 days, and whether the ticket has been used. With three conditions there are eight rows.

CancelledWithin 7 daysUsedRefund %
yesyesyes100
yesyesno100
yesnoyes100
yesnono100
noyesyes50
noyesno100
nonoyes0
nonono0

The table is complete. Each of the eight combinations has exactly one outcome. Here is the rule behind it. If the event was cancelled, the buyer gets the full amount back. If not, a purchase made in the last 7 days gets a full refund when the ticket is unused, and half when it was used. A purchase older than 7 days gets nothing.

Write the function with one if for each row. The function returns the refund percentage. The inputs come from cin, so the test runs can use one line of three numbers per run.

#include <iostream>
using namespace std;
 
int refund_percent(bool cancelled, bool within_week, bool used) {
    if (cancelled && within_week && used) {          // row 1
        return 100;
    } else if (cancelled && within_week && !used) {  // row 2
        return 100;
    } else if (cancelled && !within_week && used) {  // row 3
        return 100;
    } else if (cancelled && !within_week && !used) { // row 4
        return 100;
    } else if (!cancelled && within_week && used) {  // row 5
        return 50;
    } else if (!cancelled && within_week && !used) { // row 6
        return 100;
    } else if (!cancelled && !within_week && used) { // row 7
        return 0;
    } else {                                         // row 8
        return 0;
    }
}
 
int main() {
    bool cancelled;
    bool within_week;
    bool used;
    cin >> cancelled >> within_week >> used;
    cout << "refund: " << refund_percent(cancelled, within_week, used) << "%" << endl;
    return 0;
}

Eight rows produce eight branches. That is longer than it needs to be, because the first four rows all return 100. The code is written this way on purpose. Each row in the code matches one row in the table, so a reader can check the eight rows one at a time without thinking about the rules. The output for each combination is:

$ echo 1 1 1 | ./refund
refund: 100%
$ echo 1 1 0 | ./refund
refund: 100%
$ echo 1 0 1 | ./refund
refund: 100%
$ echo 1 0 0 | ./refund
refund: 100%
$ echo 0 1 1 | ./refund
refund: 50%
$ echo 0 1 0 | ./refund
refund: 100%
$ echo 0 0 1 | ./refund
refund: 0%
$ echo 0 0 0 | ./refund
refund: 0%

Once the code is right and the table is checked, you may merge rows that have the same outcome. The table shows that every row with cancelled set gives 100. So the first branch can check cancelled alone. This version has four branches instead of eight:

int refund_percent(bool cancelled, bool within_week, bool used) {
    if (cancelled) {
        return 100;
    } else if (within_week && used) {
        return 50;
    } else if (within_week) {
        return 100;
    } else {
        return 0;
    }
}

The merged version gives the same results. Merge only after the one-row-per-branch version works, and test the merged version against the full table. A merge that is done by eye is where a mistake can slip in.

Example 3: rows with dashes and a fixed order

Some rules say "this outcome, whatever the other facts are". A decision table can mark such a cell with a dash, which means "any value". The table is shorter, and the reader can see the rule at a glance. The cost is that the rows now overlap, so their order decides the result. The first row that matches wins.

Here is a support ticket that gets a priority from P1 (most urgent) to P4 (least urgent). The facts are whether the ticket is an outage for all users, whether the customer pays for support, and whether the severity is high.

OutagePayingHigh severityPriority
yes--P1
noyesyesP2
no-yesP3
noyesnoP3
nononoP4

Read the table from the top. A ticket with an outage is P1, whatever the other facts say. A ticket without an outage that comes from a paying customer with high severity is P2. Any other high severity ticket is P3. A paying customer with low severity is P3. Everything else is P4.

The table has five rows, not eight, because the dashes merge the combinations that give the same answer. The code still has one branch for each row, in the same order:

#include <iostream>
#include <string_view>
using namespace std;
 
string_view priority(bool outage, bool paying, bool high) {
    if (outage) {                    // row 1: outage, any other fact
        return "P1";
    } else if (paying && high) {     // row 2: paying, high severity
        return "P2";
    } else if (high) {               // row 3: any other high severity
        return "P3";
    } else if (paying) {             // row 4: paying, low severity
        return "P3";
    } else {                         // row 5: everything else
        return "P4";
    }
}
 
int main() {
    bool outage;
    bool paying;
    bool high;
    cin >> outage >> paying >> high;
    cout << "priority: " << priority(outage, paying, high) << endl;
    return 0;
}

The else if chain gives the table's "no" for the outage column without writing it again. Row 2 has no outage check, because row 1 has already returned when the outage is true. Row 3 has no paying check, because row 2 has already handled the paying high case. In a chain, each row only has to name the facts that matter at that point.

The output for six inputs is:

$ echo 1 1 1 | ./priority
priority: P1
$ echo 1 0 0 | ./priority
priority: P1
$ echo 0 1 1 | ./priority
priority: P2
$ echo 0 1 0 | ./priority
priority: P3
$ echo 0 0 1 | ./priority
priority: P3
$ echo 0 0 0 | ./priority
priority: P4

Common mistakes

The most common mistake is a missing row. If the last branch is an if without an else, a combination that matches no row falls off the end of the function. The compiler warns about that under -Wall. This sample has no else for row 4:

#include <iostream>
#include <string_view>
using namespace std;
 
string_view priority(bool outage, bool paying, bool high) {
    if (outage) {
        return "P1";
    } else if (paying && high) {
        return "P2";
    } else if (high) {
        return "P3";
    }
}
 
int main() {
    cout << priority(false, false, false) << endl;
    return 0;
}

The compiler reports:

missing_else.cpp: In function ‘std::string_view priority(bool, bool, bool)’:
missing_else.cpp:13:1: warning: control reaches end of non-void function [-Wreturn-type]
   13 | }
      | ^

The warning shows that the table has a row with no branch. Here the input false false false matches no row. The function ends without returning a value, so the result is undefined. The fix is to add the missing row, either as its own else if or as a final else. The warning does not say which row is missing. Use the table to find it by comparing each row with the branches.

The second mistake is rows in the wrong order. When rows overlap, the first matching row wins. If the rows are reordered, the answer can change. In the next sample, the "paying" row was moved above the "outage" row:

string_view priority(bool outage, bool paying, bool high) {
    if (paying && high) {
        return "P2";
    } else if (high) {
        return "P3";
    } else if (paying) {
        return "P3";
    } else if (outage) {
        return "P1";
    } else {
        return "P4";
    }
}

Calling priority(true, true, true) now prints P2. The table says the answer is P1, because a ticket with an outage is always P1. The code compiles without a warning, and the output looks plausible, so this kind of mistake is hard to see. The fix is to keep the rows in the table's order, and to test a row that matches more than one branch.

The third mistake is a row where the code and the table disagree. A table row that says "yes" for a condition should check that fact directly, as in member, and a "no" should use !member. Mixing up && and || is a common version of this mistake. For example, member || over_fifty is true for three rows, not one. Read each if line against its table row and check the operator.

The fourth mistake is testing only the easy rows. Most people test the first row that they wrote and a few others. A decision table makes it easy to test every row, because each row gives one input. Test all of them. For the refund example, that means eight runs, one for each line of the table.

Practice

Write each program, compile it with -std=c++20 -Wall -Wextra, and test every row before you read the answer. Build the table first, then write the code.

  1. A cinema ticket costs 4 for a student at a matinee, 6 for a student at another time, 7 for a non-student at a matinee, and 10 for a non-student at another time. Write the table with two condition columns and one price column. Then write the program. Read two bool values, student and matinee, and print price: followed by the price.

Show the answer

StudentMatineePrice
yesyes4
yesno6
noyes7
nono10
#include <iostream>
using namespace std;
 
int main() {
    bool student;
    bool matinee;
    cin >> student >> matinee;
 
    int price;
    if (student && matinee) {
        price = 4;
    } else if (student && !matinee) {
        price = 6;
    } else if (!student && matinee) {
        price = 7;
    } else {
        price = 10;
    }
    cout << "price: " << price << endl;
    return 0;
}

The input 1 1 prints price: 4. The input 0 0 prints price: 10.

  1. The program below has three branches and no final else. Compile it with -Wall, read the warning, and add the missing row. The table it should match is: both true gives P1, one true gives P2, and both false gives P4.
#include <iostream>
#include <string_view>
using namespace std;
 
string_view tier(bool a, bool b) {
    if (a && b) {
        return "P1";
    } else if (a) {
        return "P2";
    } else if (!a && !b) {
        return "P4";
    }
}
 
int main() {
    cout << tier(false, true) << endl;
    return 0;
}

Show the answer

The row missing from the code is a false and b true. The code has no branch for that input, so the function can end without returning a value, and the compiler warns about it. Add else if (b) with return "P2"; before the !a && !b branch, and change the last branch to a plain else that returns P4. The four rows are then: both true gives P1, a true gives P2, b true gives P2, and both false gives P4. The input false true now prints P2.

string_view tier(bool a, bool b) {
    if (a && b) {
        return "P1";
    } else if (a) {
        return "P2";
    } else if (b) {
        return "P2";
    } else {
        return "P4";
    }
}
  1. A ticket with an outage is always P1. Write the table for these three rows: outage gives P1, paying with high severity gives P2, and every other case gives P4. Then write the function and check that a ticket with an outage from a paying customer gives P1.

Show the answer

OutagePayingHigh severityPriority
yes--P1
noyesyesP2
no--P4
#include <iostream>
#include <string_view>
using namespace std;
 
string_view priority(bool outage, bool paying, bool high) {
    if (outage) {
        return "P1";
    } else if (paying && high) {
        return "P2";
    } else {
        return "P4";
    }
}
 
int main() {
    cout << priority(true, true, true) << endl;
    return 0;
}

The input 1 1 1 prints P1. The outage row is first, so it wins over the paying row.

  1. Look at the refund table in the lesson, with eight rows and the outcomes 100, 100, 100, 100, 50, 100, 0 and 0. Without running any code, count how many rows give 100, how many give 50, and how many give 0. Then check your count against the table.

Show the answer

Five rows give 100, one row gives 50, and two rows give 0. The rows giving 100 are rows 1, 2, 3, 4 and 6. The row giving 50 is row 5. The rows giving 0 are rows 7 and 8. Five plus one plus two is eight, which matches the number of rows.

  1. A library fine depends on two conditions: whether the book is more than 14 days overdue, and whether the borrower is a student. The fines are 1 for an overdue student, 3 for an overdue non-student, and 0 when the book is not overdue more than 14 days. Write the table with four rows, then write the program. Read two bool values, overdue_over_14 and student, and print fine: followed by the fine.

Show the answer

Overdue over 14 daysStudentFine
yesyes1
yesno3
noyes0
nono0
#include <iostream>
using namespace std;
 
int main() {
    bool overdue_over_14;
    bool student;
    cin >> overdue_over_14 >> student;
 
    int fine;
    if (overdue_over_14 && student) {
        fine = 1;
    } else if (overdue_over_14 && !student) {
        fine = 3;
    } else if (!overdue_over_14 && student) {
        fine = 0;
    } else {
        fine = 0;
    }
    cout << "fine: " << fine << endl;
    return 0;
}

The input 1 1 prints fine: 1. The input 0 0 prints fine: 0. Rows 3 and 4 both give 0, so they could be merged into one else branch, but writing them separately keeps each branch matched to one row.

Summary

  • A decision table has one column per yes or no condition, and one column for the outcome. Each row is one combination of answers.
  • With n conditions the table has 2 to the power of n rows, so keep the number of conditions small.
  • Write one if or else if branch for each row, in the same order as the table, and add a comment that names the row.
  • A dash in a table cell means "any value". Rows with dashes overlap, so their order decides the result.
  • Check the table against the code by testing every row, including the rows that look unlikely.
  • Merge rows with the same outcome only after the one-row-per-branch version is tested, and test the merged version against the full table again.
Check yourself: Decision tables (6 questions) →Practice this topic: coding problems for C++ Conditionals →

Stuck? Ask the tutor

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

Sign in to ask the tutor.

← Fall-through on purpose