What are good practices
Following sound design principles is important for the sustainability of our code.
Your development will be much faster if your code is clean, organized, and easy to read. Thinking about your design before starting development will save you a lot of time.
Good practices help you create programs and code that is:
- Easy to understand (Readability)
- Easy to upgrade/change (Maintainability)
- Performance optimized (Efficiency)
- Easy to find (Organization)
- More stable and less likely to break (Robustness)
- Protected against malicious attacks (Security)
- Easy for the user to understand (User Experience)
In this chapter you'll learn how to write code with high readability, maintainability, organization, and robustness.
Write Self Documenting Code
Self-documenting code is code that is so easy to read that you understand it at first glance without comments or documentation. Easier to understand means easier to work with.
Descriptive Variable & Function Names
One of the most important aspects of code readability is our function and variable names.
Here's some code that needs better variable and function names.
Good luck figuring out what it means and what it does.
if (a >= 18 && a <= 65 && !i)
{
gtw();
}
Here's the same code with better variable and function names.
if (age >= 18 && age <= 65 && !worker_injured)
{
goToWork();
}
Using descriptive names reveals the meaning behind the code. The program will make someone go to work with the goToWork() function if they are 18 to 65 and not injured.
Avoid Magic Numbers
Magic numbers are values that appear in the code without any context or explanation about what they are or represent.
Here, we can see two magic numbers. A reader can see that age has to be 18 to 65, but it can be hard to see why.
if (age >= 18 && age <= 65)
{
goToWork();
}
We can add context by moving the values into variables. Now, it's easy to tell that we are checking if they have started working and have yet to retire.
int min_working_age = 18;
int retirement_age = 65;
if (age >= min_working_age && age <= retirement_age)
{
goToWork();
}
Using variables for values also makes it easier to maintain your code. If the retirement age changes, you have to replace every instance of 65 used for retirement age with the new age. You only have to change one instance (retirement_age) if you use variables.
How to Use Functions
Don't Repeat Yourself
Focusing on reusing your code instead of repeating it will lead to many benefits. Using variables instead of magic numbers lets you change the value everywhere by changing one value. The same principle applies to using functions and loops.
Do you do the same thing many times in your program?
Replace it with a function. Now, it will be easier to change.
Do you do the same thing many times after each other?
Replace it with a loop.
Start identifying code you can template and reuse.
Single Purpose Functions
When a function does many things, it becomes long, hard to read, and unreusable. You should create functions that perform singular tasks. A good rule of thumb is that you shouldn't need to scroll to see the entire function. It is often better to divide longer functions into many smaller functions.
I'll include a short example for the sake of brevity. Have a look at your code and see if you have any long, messy, and hard-to-read functions, and break them up a bit.
calculateAndPrintArea is a niche function. If you're making a program with rectangle geometry, you'll rarely use this function.
#include <iostream>
// This function calculates and prints the area of a rectangle
void calculateAndPrintArea(int width, int height) {
int area = width * height;
std::cout << "The area of the rectangle is: " << area << std::endl;
}
int main() {
int width = 10;
int height = 5;
calculateAndPrintArea(width, height);
return 0;
}
You can improve the versatility of this function by separating it into two functions; calculateArea and printArea.
#include <iostream>
//function to calculate the area of a rectangle
int calculateArea(int width, int height) {
return width * height;
}
//function to print the area
void printArea(int area) {
std::cout << "The area of the rectangle is: " << area << std::endl;
}
int main() {
int width = 10;
int height = 5;
int area = calculateArea(width, height);
printArea(area);
return 0;
}
Commenting
When you're new to programming, it's easy to go overboard when you hear that you should comment your code. I've seen many projects filled with more comments than code.
You should use comments to add context or to save time. A comment for a self-explanatory piece of code will only slow the reader down.
Comments like these are only helpful if you are writing code for a programming course.
// This declares a variable of datatype "float" with the name "rect_length" and assigns the value of 4 to it
float rect_length = 4;
A good rule of thumb for comments is that it should explain what a piece of code does or why it does it, not how it does it.
// Calculate the area of a rectangle
float rect_length = 4;
float rect_width = 7;
float rect_area = rect_length * rect_width;
Namespaces
We touched on namespaces when explaining the syntax of std::.
You can group your code under a namespace to improve readability and organization.
namespace namespace_name {
// Your code here ...
}
Any code you put inside the namespace requires you to prefix it with namespace_name:: if you want to access it outside the namespace scope.
Namespaces also help you avoid naming conflicts.
Naming conflicts occur when two variables or functions have the same identifier (name).
Namespaces prevent overlap by separating these identifiers into distinct regions.
namespace ns {
int example1 = 1;
}
int example1 = 2;
int example2 = 3;
int example2 = 4; // Error trying to declare a variable with an existing identifier
int example3 = 5;
int main()
{
// This is acceptable because it is in a different scope than the global 'example3' variable.
// But there are still downsides and it can be confusing when trying to read the code
int example3 = 6;
std::cout << example1; // Uses global scope (prints 2)
std::cout << ns::example1; // Uses identifier in namespace (prints 1)
std::cout << example2; // ERROR: Doesn't know which variable is being referred to
std::cout << example3; // Uses innermost scope (prints 6)
}
You can also nest namespaces for further organization.
namespace outer {
namespace inner {
void example() {
std::cout << "Example function";
}
}
}
int main() {
outer::inner::example();
}
In modern versions of C++, you can also use this notation to refer to the same namespace:
namespace outer::inner {
void example() {
std::cout << "Example function";
}
}
Using namespace x;
using namespace namespace_name; can be helpful when using many objects inside a namespace.
If there isn't an immediately accessible matching symbol, the program will look for it inside the used namespace.
The compiler first looks for cout in the int main() scope, then the global scope.
When/if it can't find an object called cout it will look again inside the std namespace for it.
using namespace std;
cout << "This will print, even though there isn't a cout stream in > the global scope.";
cout << "Because of the 'using namespace' line above, the compiler looks for the cout object inside std once it isn't found in the normal scope.";
I rarely use using namespace because I like the clarity of knowing where all my symbols come from at a glance. However, some objects are inside many namespaces, so referencing them frequently can be bothersome.
The effect of using namespace will continue to the end of the scope you declared it in. Because of this, you have to be wary of what scope you use it in.
Putting using namespace x; in a global scope will pollute the scope and remove the value of having the namespace in the first place.
Const
The const keyword stands for constant.
You cannot change the value of a variable declared as const after initialization.
// Contains important information that won't change during the program, so we want to avoid accidental changes.
const int screen_width = get_screen_width();
Declaring a variable as const keeps it safe from unintended alterations. It sets clear boundaries and expectations within your code. This makes it more understandable and maintainable.
The program won't compile if you try to perform a non-const-compatible operation on a const variable, such as an assignment (=).
const variables are perfect for avoiding magic numbers.
You can only use const operations and member functions on a const container (like const std::vector).
const operations and methods don't change the data of a container.
Functions like std::vector::pop_back() and std::string::insert() change the data, so they won't work.
Functions that don't change information, like std::string::length(), will work.
Enums
Enumerations, or enums, are a custom data type consisting of named constants. They function much like a list of const declared values. However, they add a structure by grouping the constants.
You can declare a variable using the enum identifier to show that its value is from the enum.
This code defines an enum with the Operation identifier and declares and defines an Operation type variable as an element of that enum.
enum Operation {
ADD,
SUBTRACT,
MULTIPLY,
DIVIDE
};
Operation default_operation = Operation::ADD;
In the same way a bool can only hold two values (true or false), you can customize your own datatype that can only hold specific values you declare. Enums are great for representing a fixed set of related options, states, or categories.
Enums are great for switch statements because they give context to each case.
This gives you no information, you would have to read into what ... does to find out what it does.
switch (variable)
{
case 0:
...;
}
This is much better, you can infer that ... will have to be the result of summing numbers.
case Operation::ADD:
...;
Assigning enumerator values
You can assign integer values to the enumerators.
enum Operation {
ADD = 1,
SUBTRACT = 2,
MULTIPLY, // Will be 3 by default
DIVIDE // Will be 4 by default
};
Doing so will determine what value the enumerators have if used as or cast to an integer. If you don't specify the integer value of a member it will default to be 1 higher than the last member. If there is no member before, it defaults to 0.
In the first Operation enum (listed without numbers) the value of Operation::ADD is 0. In the second enum (where the first 2 elements have a value) Operation::ADD is 1.
Enum Types & Access
Enums are integers in disguise. It has the same size as an int, and you can use it as an int if you cast it using int(enum_variable).
You can change the underlying type of the enum, but it has to be an integral type, such as char, unsigned int, short, etc.
To do this, follow the enum name with : datatype
enum Operation : char {
ADD = '+',
SUBTRACT = '-',
MULTIPLY = '*',
DIVIDE = '/'
};
Now std::cout << Operation::ADD will implicitly cast to a char.
If you assign char values to the enumerations without specifying the enum to be of a char type the compiler will treat them as integers (based on ASCII codes).
Upgraded calculator example
Here is our old calculator program using enums to increase readability.
enum OperationChoice : int {
ADD = 1,
SUBTRACT,
MULTIPLY,
DIVIDE
};
int main()
{
std::cout << "Input your 2 numbers and the operation you wish to perform.\n";
std::cout << OperationChoice::ADD << "=Add, "<< OperationChoice::SUBTRACT << "=Subtract, " << OperationChoice::MULTIPLY << "=Multiply, " << OperationChoice::DIVIDE << "=Divide";
float in1, in2;
int op_type_buffer;
// Assuming the user gives a valid input
std::cin >> in1 >> in2 >> op_type_buffer;
OperationChoice op_type = OperationChoice(op_type_buffer)
switch (op_type)
{
case OperationChoice::ADD:
std::cout << "sum is: "<<in1 + in2;
break;
case OperationChoice::SUBTRACT:
std::cout << "sum is: "<<in1 - in2;
break;
case OperationChoice::MULTIPLY:
std::cout << "sum is: "<<in1 * in2;
break;
case OperationChoice::DIVIDE:
std::cout << "sum is: "<<in1 / in2;
break;
default:
std::cout << "Invalid operation code, please use a number between 1-4";
}
}
We use enums in the prompt to the user and in the switch statement. This tells the reader of our code what each case does. Using the enum values in the prompt to the user ensures that the presented options will correspond to the executed code.
Enum access
You can access the enums constants without using the enum_name:: syntax. This is convenient but worse for readability. It restricts similar namings, making it problematic to have 2 Enums within the same scope sharing element names.
Using the struct or class keyword will make the enum name-prefix required to access an element.
This functionality is not connected to the class and struct keywords discussed in the next chapter.
enum struct OperationChoice : char {
ADD = '+',
SUBTRACT = '-',
MULTIPLY = '*',
DIVIDE = '/'
};
Signed & Unsigned
The computer stores values as 1's and 0's, known as binary. One of these 1's or 0's is known as a bit.
Many integral datatypes reserve the first bit to track if a number is negative. This is called the "sign bit". It's dedicated to showing if the value is negative.
int is signed by default, which lets it hold negative numbers. However, not all datatypes hold negative values. char is unsigned by default, so it can only be positive.
Using unsigned in front of the signed int will remove its ability to store negative numbers. As a side effect, the range of positive numbers it can store doubles.
- An
int (consisting of 32 bits) can hold values from -2,147,483,648 to 2,147,483,647 ( -2^31 to 2^31 - 1).
- An
unsigned int can hold values from 0 to 4,294,967,295 (from 0 to 2^32 - 1).
Using signed char will change the range from 0 to 255 (0 to 2^8) to -127 to 128 (-2^7 to 2^7 - 1)
unsigned int index = 0;
The main reason to use unsigned variables is to clarify that the variable isn't supposed to be negative.
Values for array indices, array sizes, ages, etc., don't make sense to be negative.
This is good to use for readability and optimization, but care should be taken when using unsigned variables.
Mixing signed and unsigned types in calculations can lead to unexpected results or bugs.
This is similar to the surprises one might encounter when mixing integers and floats due to differing arithmetic behaviors and type casting.
Be aware of the implications and potential pitfalls of using unsigned types in your code.
Static
You can declare a static variable with the static keyword before the datatype.
static int example = 5;
Static variables function like variables declared in the global scope.
However, it restricts their access to the scope they are declared in.
Try running this program and observe the behavior for yourself.
#include <iostream>
void function_example()
{
static int counter = 0;
std::cout << "Static function var: "<<counter++ << "\n";
}
void function_example2()
{
int counter = 0;
std::cout << "Normal function var: "<< counter++ << "\n";
}
int main()
{
for (int i = 0; i< 15; i++)
{
function_example();
function_example2();
static int r_counter = 15;
int r_counter2 = 15;
std::cout << "Static loop var: " << r_counter-- << "\n";
std::cout << "Normal loop var: " << r_counter2-- << "\n";
}
}
Because counter, and r_counter are static variables, they aren't re-initialized. Their value doesn't get reset to 0 and 15 every loop.
Templates
You don't have to write the same function for different datatypes and rely on function overloading.
Instead, you can write a single function that works with any data type.
Use the template keyword followed by a set of type parameters enclosed in angle brackets to declare a template.
These type parameters act as placeholders for the types used when you call the function.
template <typename T>
void print_sum(T a, T b) {
std::cout << a + b;
}
We can specify the datatype we want T to be when calling the print_sum function by passing it as a parameter inside angle brackets.
print_sum<float>(2.f, 4.5f); // Will print 6.5
We can also call it without specifying the datatype, in which case it will infer the datatype based on our parameters.
print_sum(2, 3); // Will be treated as print_sum<int> and will print 5
The compiler constructs a function based on your template with whatever datatype you used when calling it.
Templates give a lot of code reusability and flexibility.
Many standard library containers, such as std::vector, use templates to give flexibility for datatypes.
You can have multiple type parameters in a template.
template <typename T1, typename T2>
void print_pair(T1 a, T2 b) {
std::cout << a << ", " << b;
}
int main()
{
print_pair<std::string, int>("Hello World!", 11); // prints: Hello World!, 11
}
GOOD JOB!
You finished a chapter.
This would be a good time to practice what you've just learned.
Last ChapterPracticeNext Chapter