These tutorials haven't covered arrays until now because the intention was to provide an understanding of how arrays are actually structured in system RAM so as better to contrast them with how dynamic structures like std::vector
differ. That said, Chapter 2 was all about memory, so we are now equipped to discuss them.
Arrays have a self-explanatory name; they are data structures that contain a "list" of data of the same type. For example, one could create an array of integers, or an array of doubles, or an array of std::string
s, et cetera. The catch with arrays is that they cannot be expanded or contracted once instantiated, meaning that the size defined at instantiation sticks with the array for the entirety of its life cycle. (until deleted)
Arrays are stored in system RAM as a contiguous block, long enough to store the desired number of elements. For example, bringing back the hypothetical 8-bit computer from last chapter, this is what a hypothetical 4-element long integer array could look like in RAM.
Address | Data |
---|---|
00000000 | 10001100 |
00000001 | 01010010 |
Element 1: (index 0) 00000010 | 01000100 |
Element 2: (index 1) 00000011 | 01101000 |
Element 3: (index 2) 00000100 | 00100100 |
Element 4: (index 3) 00000101 | 00010000 |
00000110 | 01111010 |
00000111 | 00011110 |
Figure 3.1.1: Depiction of system RAM structure with an integer array
It is for the reason that arrays must occupy contiguous memory spaces that arrays have a fixed size once defined.
C++ inherits native support for arrays from it's predecessor, C. C-style arrays can be instantiated like so:
int nums[10];
// OR
double doubles[] = {1.2, 3.14, 6.28};
// etc.
The first example just creates an empty array of integers 10 elements in length, and the second example initializes an array of doubles three elements in length, initially filled with the numbers 1.2, 3.14, and 6.28. Once initialized, elements can be accessed like so:
// Continuation of previous sample...
nums[0] = 10;
nums[3] = 400;
nums[2] = 404;
doubles[0] = 3.14159;
std::cout << doubles[1] << std::endl;
Note that array indices begin at zero, meaning that the last the last element in the array is located at index length - 1. Square brackets, []
, are used to specify which index data should be read from or written to. Getting the length of a C-style array is a surprisingly difficult endeavor, involving the following syntax:
// Continuation of previous sample...
std::cout << "Size of doubles array: " << sizeof(doubles)/sizeof(doubles[0]) << std::endl;
sizeof
is syntax directly inherited from ancient C, with which C++ was intentionally designed to be forwards compatible with. For this reason, (and a few others) most programmers now recommend using std::array
instead, which has a far more usable API, for applications that don't require the multi-dimensional capabilities of C-style arrays. For that reason, we'll be returning to these types of arrays when we discuss multi-dimensional data structures.
The same basic principles of C-style arrays apply to the implementation of std::array
, introduced as a part of C++11. Here are the same examples from above, slightly modified to work with std::array
. Note that the array
header must be included to use these.
std::array<int, 10> nums;
std::array<double, 3> doubles = {1.2, 3.14, 6.28};
nums[0] = 10;
nums[3] = 400;
nums[2] = 404;
doubles[0] = 3.14159;
std::cout << doubles[1] << std::endl;
std::cout << "Size of doubles array: " << doubles.size() << std::endl;
Much cleaner! Note that std::array
makes use of two template, (which we covered last chapter) one for the type that the array will hold and another (this time a template parameter, not type) which defines the length of the array.
A new type of for
loop called the for-each loop is available for iterating through the elements in the array:
for(double val : doubles) {
std::cout << val << std::endl;
}
as opposed to the other, more traditional, manner for iterating through an array:
for(int i = 0; i < doubles.size(); i++) {
std::cout << doubles[i] << std::endl;
}
which is just generally less concise and clean. However, as hinted at by the syntax, the for-each loop cannot be used to modify the contents of the array, as the newly created object is distinct and not linked with its respective element in the array.
The primary limitation of arrays, C-style or std::array
, still remains though; the size of the array cannot be changed, increased or decreased. That is, unless you use a std::vector
...
std::vector
works in a hyper-similar manner to std::array
, with the primary upgrade being that the size of the list isn't fixed. Like std::array
, std::vector
requires an extra header to be included, in this case, vector
. However, the exact syntax used does differ somewhat.
std::vector<int> nums;
std::vector<double> doubles = {1.2, 3.14, 6.28};
nums.push_back(10);
nums.push_back(400);
nums.push_back(404);
doubles[0] = 3.14159;
std::cout << doubles[1] << std::endl;
std::cout << "Size of doubles array: " << doubles.size() << std::endl;
std::cout << "Elements in doubles array" << std::endl;
for(double val : doubles) {
std::cout << val << std::endl;
}
More functions are available to be called of a vector class, which can be found by visiting the cppreference.com page for std::vector
, like .clear
, .erase
, .insert
and more.
As promised, we're going to be revisiting ye' old C-style arrays for the purposes of learning about multidimensional arrays. These aren't all too common in C++, hence the less than stellar framework of C++ styled implementations like those that exist for single-dimension arrays. In fact, most of the time, when dealing with multi-dimensional data, it is preferred to use a specialized vector and matrix library like Armadillo, blaze or the vastly more popular eigen. However, it is sometimes useful to be able to use native multidimensional arrays, so here we go.
// Defining multidimensional arrays
int nums[3][3][3];
double nums2[3][3] = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}};
nums[0][1][2] = 10;
nums[1][2][3] = 999;
nums[3][2][1] = 314;
std::cout << nums2[1][0];
// etc.
Each dimension of the array is specified in its own set of square brackets, for as many dimensions as desired, although the syntax becomes more and more cumbersome with each additional dimension. Note that the size of the array MUST be provided within the square brackets at initialization, even in the case of immediate specification of data like in the case of nums2
. Again, these aren't a very common occurrence in day-to-day C++ programming, so dealing with the specifics of syntax and usage when the application comes isn't a bad solution.
In the previous chapter, we briefly discussed the constructor and destructor member functions, but only made use of the destructor to free the memory associated with the linked list when the SinglyLinkedList
was destroyed. We're going to talk about them, as well as some more fun class-related information now.
As alluded to before, both the constructor and destructor have the same name as the parent class and no return type. i.e.
class Test {
public:
// Test Class Constructor
Test(int num) {
number = num;
}
// Test Class Destructor
~Test() {
// Nothing really to do here, no heap objects to free
}
private:
int number;
};
As used in the previous chapter, the sole purpose of destructors is to clean up member objects that wouldn't normally be freed in a regular destruction, usually only heap objects. The role of constructors is to set up the class to be used before any other methods are called. The constructor is automatically called when instantiating a class. For example:
Test test = Test(7);
//OR
Test test2(5);
The parameters to the class instantiation are passed onto the constructor, which does its thing to set up the class, usually stuff along the lines of initializing member objects, before other member functions can be called.
Say we wanted to create a class with a member instance of the Test
class we just defined. However, the Test
constructor requires a parameter, so we need to feed it one when the instance is created. We might start by trying to write this:
class MyClass {
public:
MyClass(int num) {
test(num);
}
private:
Test test;
};
but this code results in two errors, one when the constructor parameter isn't provided for Test test
and another when test(num)
is run because the constructor cannot be called after the class is instantiated. This is because the code above roughly translates into this:
Test test;
test(num);
We can address this with something called a constructor member initializer list. They allow us to instantiate classes and provide definitions to member objects before they are created. Here is an example:
class MyClass {
public:
MyClass(int num) : test(num) {}
private:
Test test;
};
In the code above, the colon represents the start of the constructor member initializer list, and here we can call the Test
class constructor. This results in the test object being instantiated with the value num
passed in.
These are also very handy when dealing with constants. Like classes, const
variables can only be set once, at definition. This means that:
const int thing; // Compiler Error: no value provided
thing = 10; // Compiler Error: const variables cannot be modified after definition
but, now we have a way of resolving this issue in classes with a constructor member initializer list:
class MyClass {
public:
MyClass(int num) : test(num), constant(num) {}
private:
Test test;
const int constant;
};
Function Overloading is a fun and useful feature that allows us to define multiple functions with the same name but with different parameters. Normally, if two functions with the same name exist in the same scope, the compiler throws a fit:
void thing() {
//...
}
void thing() { // Compiler Error: function redefinition isn't allowed
//...
}
However, if they have different parameters...
void thing() {
//...
}
void thing(int num) {
//...
}
then there isn't any issue. This practice is called function overloading. It is useful for when there is a need for two different versions of a function that would do the exact same thing, just with slightly different outputs. When the function is called, the compiler will look for the best fit function of the bunch to call based on the parameters passed in. There is no practical limit to how many times a function can be overloaded.
Default parameters are very similar to function overloading. In this, parameters are given a default value that they will be set to if a value isn't provided. For example:
void thing(int num = 404) {
//...
}
In this case, if thing()
is called, then the function will proceed assuming that num
equals 404. Otherwise, num
takes on the value passed in. In the case where multiple parameters are required to a function call, the default parameters must come last:
void thing(double decimal, int integer, int num = 0) {
//...
}
The reason why becomes apparent considering the above scenario. If the integer
and num
parameters were switched, and thing(3.14, 10)
was called, how would the compiler be able to decide which the programmer meant?
thing(decimal = 3.14, N/A, integer = 10)
thing(decimal = 3.14, num = 10, ??)
The first option seems okay, but the second should result in a syntax error, and both would likely result in wildly different results.
Another important but small bit of information to be had regards the break
and continue
keywords. These are applicable in loops, like the for
and while
, as well as in a couple other situations.
Break is applicable in loops as well as the switch
block. Let's look at its use in loops first.
for(int i = 0; i < 10; i++) {
std::cout << i << std::endl;
if(i == 4) break;
}
Running this block of code would print out the numbers 0-4 each on separate lines. The loop could iterate until i == 10, but the loop is cut short when i == 4 because the break
command is issued, breaking out of the loop right there. This is useful when a loop needs to be exited, but the return
keyword shouldn't be issued.
The other use of break
is in a switch
block. These are like an extended if-else if-else if-else block all smooshed into one, but it is only applicable for equal boolean expressions. (==) What this means is that the condition, if written into an if statement must have an equal comparison as the only logical operation. For example:
int value = 4;
switch(value) {
case 0:
// Do thing 1
break;
case 1:
// Do thing 2
break;
case 2:
// Do thing 3
break;
case 5:
// Do thing 4
break;
default:
// Do thing 5
break;
}
which is equivalent to
if(value == 0) {
// Do thing 1
} else if(value == 1) {
// Do thing 2
} else if(value == 2) {
// Do thing 3
} else if(value == 5) {
// Do thing 4
} else {
// Do thing 5
}
Much more concise, amirite? In the switch
block,, the break
statement serves to break execution out of the switch block after the code associated with the proper case is complete. If the break
wasn't there, every case afterward would also be executed. For example, if case 2 was run, case 5 and the default case would also run, usually an undesirable behavior.
The continue
statement is pretty much only useful in for
loops. Consider the following example:
for(int i = 0; i <= 10; i++) {
if(i % 2 == 0) continue;
else std::cout << i << std::endl;
}
The above code would output the following:
$ g++ test.cc && ./a.out
1
3
5
7
9
$
Any guesses as to what the continue
statement does? Vote now!
Time's up! The correct answer is: the continue
statement skips the rest of the code in the for loop and re-evaluates the condition. In laymen's terms, the continue
statement jumps back to the top of the loop.
This is useful if certain iterations in a loop shouldn't be run, but the loop needs to continue running.
Notice that we've been putting the keywords public:
and private:
into our classes to delineate which member functions and objects can be accessed from where. Well, there's a third access specifier in C++, called protected:
. Here's what they all do:
public:
Accessible from anywhere: inside the class, in derived classes, from the outsideprivate:
Only accessible from inside the classprotected:
Hybrid of the above two: accessible from inside the class and in derived classes
Child classes have to do with class and struct inheritance, a fun topic in its own right.
A principal feature of OOP is what's called polymorphism, which includes the ability for objects to inherit properties from others. Classes and structs are the posterchildren of this feature. For example, say I have a rectangle class:
class Rectangle {
public:
Rectangle(int length, int width) {
length_ = length;
width_ = width;
}
int getArea() {
return length * width;
}
private:
int length_;
int width_;
};
and I want to make a square class. One could write all of this from scratch, or one could also just do this:
class Square : public Rectangle {
public:
Square(int length) : Rectangle(length, length) {}
};
and be able to use the Rectangle
class's getArea()
member function. The : public Rectangle
is the part that claims inheritance from the Rectangle
class, with the public
being the maximum access specifier that member functions and objects in the base class can have in the child class; protected
and private
are also acceptable access specifiers here. Next up, we see that the rectangle class's constructor is called in a member initializer list. This piece is responsible for the instantiation of the rectangle class.
This cplusplus.com tutorial goes over inheritance in a far greater depth.
An interface is a special type of class that creates a blueprint for other classes to be based upon. It creates a set of "virtual" functions that should (and sometimes must) be implemented by derived classes, for these virtual functions don't contain a full implementation or any code at all. For example:
class Quadrilateral {
public:
Quadrilateral(int side1, int side2) {
side1_ = side1;
side2_ = side2;
}
virtual int getArea() {
return side1_ * side2_;
}
private:
int side1_;
int side2_;
};
is an example of an interface, Note how getArea()
is defined as virtual, a hint to programmers inheriting from this class that they might need to reimplement the function. However, the derived class doesn't need to override the getArea()
function unless this is written:
class Quadrilateral {
public:
Quadrilateral(int side1, int side2) {
side1_ = side1;
side2_ = side2;
}
virtual int getArea() = 0;
private:
int side1_;
int side2_;
};
because now, getArea()
is defined as being pure virtual, meaning that the function actually doesn't exist until the derived class implements the function. As soon as a class or struct has at least one pure virtual function in it, it is then called an abstract class or struct. In fact, abstract classes and structs are barred from ever being instantiated by themselves, since their functionality is seriously hampered by the pure virtual functions
Again, this cplusplus.com tutorial goes into far greater depths into interfaces and abstract classes.
While not being a super crucial type in C++, when enumerations are applicable, they make what they do SO damn easy and easy to read. For that reason alone, they are worth knowing about.
Fundamentally, enumerations are just an integer variable, but the façade they put up on the front end makes dealing with states so much easier. Say we wanted a class to keep track of what state it was in . (e.g. processing data, saving data to a file, reading data from a file, et. cetera) We could just create an integer member variable and assign meaning to each number, (e.g. -1 means an error occurred, 0 means doing nothing, 1 means reading, 2 means writing, et. cetera) but that would get clunky over time, and it wouldn't be immediately obvious just from reading the code that that is what is happening.
Instead, we could declare an enumeration and use it instead. For example:
enum class State {WORKING, STOPPED, ERROR};
State state = State::ERROR;
creates an enumerated class that has possible states State::WORKING
, State::STOPPED
, and State::ERROR
. The enum class
line creates the class, the blueprint for the enumeration, and the State state
line creates the object state
that can be set and read.
Performing a comparison against the state of an enumeration is easy:
if(state == State::Stopped) { /*...*/ }
Function pointers are another very interesting feature in C++ that allows functions to be passed around as objects. This is especially useful for systems like timers where the program might want the timer to run a bit of code when the timer expires, or other systems where so called "callback" functions are applicable. Function pointers are handled by the std::function<TYPE(PARAMS...)>
object, contained in the header functional
. A function pointer can be created from a properly defined function like so:
void doSomething(int num) {
//...
}
std::function<void(int)> callback = doSomething;
Note that the template type must match the return type and parameter type list of the function it stores. Then, the function can be called like so:
callback(1234);
However, another method for creating function pointers exists: anonymous functions. Also called lambdas, these are a common concept in many object-oriented languages, allowing functions to be declared without a given name for s specific use. Here's an example of lambda syntax:
[CAPTURE](PARAMS) {/* CODE */}
The parameters and code areas are pretty self explanatory, but what's a capture? Well, captures allow for certain aspects of the current scope to be, well, captured into the lambda. For example, if an object in the surrounding scope is imperative to the operation of the lambda, then that object can be captured in the lambda. An example of a lambda with a capture is as follows:
int state = 4;
std::function<void()> callback = [state]() {
if(state > 2) std::cout << "yay, the state is greater than two!" << std::endl;
else std::cout << "Oh no, the system broke..." << std::endl;
}
// Much later...
callback();
Now, there are a couple different methods that can be used for lambda captures:
- Capture by value: The value of the captured object is recorded and used at the point in time when the lambda is defined
- Capture by reference: The value of the captured object is recorded and used at the point in time when the lambda is executed
An excellent demonstration of this behavior can be found on crascit.com:
int x = 5;
auto copyLambda = [x](){ return x; }; // Capture by value
auto refLambda = [&x](){ return x; }; // Capture by reference
std::cout << copyLambda() << std::endl;
std::cout << refLambda() << std::endl;
x = 7;
std::cout << copyLambda() << std::endl;
std::cout << refLambda() << std::endl;
The above code produces the following output:
$ g++ ./test.cc && ./a.out
5
5
5
7
$
As you can see, the value captured by the reference capture lambda changed by the time it was executed for a second time, while the value capture lambda didn't.
Lambda captures can contain more than just object names. There are three special capture tools to help make capture syntax more readable:
this
: Capture everything in the current class (therefore only applicable form within a class) by value (not allowed to capturethis
by reference)=
: Capture all objects in the current scope by value&
: Capture all objects in the current scope by reference
Once again, crascit.com has a very nice example code block to demonstrate this:
class Foo {
int x;
public:
Foo() : x(10) {}
void bar() {
// Increment x every time we are called
auto lam = [this](){ return ++x; };
std::cout << lam() << std::endl;
}
};
Foo foo;
foo.bar(); // Outputs 11
foo.bar(); // Outputs 12
int x = 10;
int y = 14;
auto lam1 = []() { return 24; }; // OK: capture nothing
auto lam2 = [=]() { return x+y; }; // OK: copy x, copy y
auto lam3 = [&]() { return x+y; }; // OK: reference x, reference y
auto lam4 = [=, &x]() { return x+y; }; // OK: reference x, copy y
auto lam5 = [&, x]() { return x+y; }; // OK: copy x, reference y
auto lam6 = [&x, =]() { return x+y; }; // Error: default must be first
auto lam7 = [=, x]() { return x+y; }; // Error: both specify copy x
auto lam8 = [=, this]() { return x+y; }; // Error: both specify copy this
auto lam9 = [&, &x]() { return x+y; }; // Error: both specify reference x
The auto
keyword used here is another useful tool that allows the programmer to have the compiler decide what type the object needs to have, a capability best used sparingly because of the ambiguity it creates in the code for other humans. As demonstrated in the above sample, a multiplicity of captures can be utilized to specify exactly what should be captured and how.
Many programmers make the argument that such "catch all" captures should be used sparingly, an not without necessity because of how they often result in unnecessary objects being captured, wasting memory space and processor cycles, a highly undesirable effect on systems with a speed or memory space crunch. But frankly, with the speed of modern computers, it really doesn't smatter in most cases.
Every program, application, script, etc. on a computer when executed launches one (or more) "thread"s of execution. Each thread operates pseudo-independently from one another, only sharing resources when explicitly told how and without complete knowledge of each other. Threading is responsible for the multitasking paradigm that we rely on so heavily with modern computers, enabling us to have more complex programs and open multiple programs concurrently, a capability completely essential to our use of technology in the present day. Launching a new thread, also called a process, is useful incredibly useful when trying to make two things happen at the same time, such as waiting for user input from a GUI (graphical user interface) and doing calculations in the background simultaneously.
At a low-level, threads operate wildly differently based on which operating system is being programmed for, but the basic idea is the same. I really like the explanation given by @Leos313 on this Stack Overflow question, so I'll briefly paraphrase it here. The main thread (initial program launched by the user) can at any point create a new thread to run aside it, giving it a routine to perform after which it will exit and cease to exist. The main thread can check up on the threads it launches, ("worker threads") checking to see if they are complete, but it can't do much more without some fancy footwork. If the main thread ends before the worker threads, the workers will also exit, generating an error:
terminate called without an active exception
to signify that they were interrupted before they could finish.
Threading is a special feature, as the operating system needs to specifically support multithreading (the act of having more than one thread running at a time) For this reason, the C++ standard doesn't outright include full support for threading so that it remains compatible with operating systems that don't support multithreading; it contains function definitions, but will fail to link (a part of the compilation process) without something extra to provide the method for interacting with the multithreading features of the particular platform.
Without providing the required materials, the C++ linker will produce an error like this when trying to compile the program:
Linux/MacOS
tmp/cc8sIoP4.o: In function `std::thread::thread<void (Test::*)(), Test*>(void (Test::*&&)(), Test*&&)':
test2.cc:(.text._ZNSt6threadC2IM4TestFvvEJPS1_EEEOT_DpOT0_[_ZNSt6threadC5IM4TestFvvEJPS1_EEEOT_DpOT0_]+0x33): undefined reference to `pthread_create'
collect2: error: ld returned 1 exit status
Windows:
./test.cc:8:3: error: ‘thread’ is not a member of ‘std’
Setting up such implementations of C++ multithreading differs between operating systems.
Most operating systems based on Unix, a highly foundational OS built by Bell Labs in 1969, such as all Linux distributions and MacOS derive their threading capabilities from implementations of the NPTL (Native POSIX Thread Library) or similar APIs based on the POSIX Thread spec, often shortened to pthread
, a term you might recognize from the above error message.
Using pthread
is simple, requiring it to be "linked" into the compilation process. In g++
, just add the -pthread
or lpthread
flag. (the l
stands for link)
$ g++ test.cc -pthread
OR
$ g++ test.cc -lpthread
Windows users (again) have a harder experience, as the MinGW package that must be installed for g++
to be used must be installed with an implementation of POSIX pthread
s to be able to compile the C++ thread
header.
Such resources as this Stack Overflow post and the linked-to MinGW build exist for those who are willing to weather the storm of potential hurdles present here, however this is another example of a way in which the newly-introduced Windows Subsystem for Linux (WSL) for Windows 10 briefly discussed in Chapter 1 takes the cake in terms of simplicity. As its name describes, because it runs a modified Linux (specifically Ubuntu) OS on top of Windows, it is able to make full use of the standard pthread
implementations that ordinary Linux OSes can.
Due to the relatively simple API inherent to threading due to the limited ability of the main thread to interact with the worker threads, the actual API of the C++ thread
header is pretty simple.
#include <thread>
#intlude <iostream>
void function() {
std::cout << "This is the worker thread" << std::endl;
}
int main() {
std::thread worker; // No new thread is created yet, as there is nothing for it to do...
worker = std::thread(function); // A new thread is created, and the function "function" runs
std::cout << "This is the main thread" << std::endl;
// If the worker thread is still running, we need to make sure that it terminates before the main thread to avoid the "terminate called without an active exception" message.
if(worker.joinable()) { // Check to see if the worker thread is still running
worker.join(); // If it is, wait for it to terminate on its own
}
return 0;
}
Possible output:
$ g++ test.cc -pthread && ./a.out
This is the worker thread
This is the main thread
$
Note that this only one of the possible outputs generatable by the above example. Because a new thread is spawned, there is really no defined behavior as to which of the threads will execute which commands in order. Depending on the circumstances, it may very well be possible that the main thread prints its line before the worker thread.
Threads can also be spawned from functions that require parameters:
void function(int a, int b, int c, /*...*/) {
//...
}
//...
std::thread worker(function, 1, 2, 3, /*...*/);
The return type of the function, however, should be void, as all other return types are promptly ignored by the new thread. Since there is no simple way for the worker thread to communicate back with the main thread, there is no way for the C++ compiler to get the returned value back to the caller.
An event loop is a construct that is central to the vast majority of computer programs, helping them manage sequences of events and tasks in an orderly fashion. Different parts of the program schedule events with the event loop, such as "let me know in 10 minutes", "let me know when the internet comes back", etc. with the expectation that the event loop is on top of what is going on and will "wake up" the program that asked to be notified.
There are almost an infinite number of variations and permutations possible to an event loop and surrounding infrastructure, but we're going to create a rather simple event loop that sequences events given to it, i.e. executes events one after another. To do this, we're going to enlist concepts from this chapter, including std::vector
, lambdas, and more.
Like we did last chapter, let's think about this project in terms of the required API, which this time is pretty simple:
addTask(std::function<void()>)
: adds the given function to the list of tasks to executerun()
: start processing the events already in the liststop()
: interrupt the actions ofrun()
clearAll()
: get rid of any tasks already added
The list of tasks inside the class will take the form of an std::vector
so that we can dynamically add more tasks as they come in.
#include <functional>
#include <thread>
#include <vector>
class EventLoop {
public:
EventLoop() { stopWork_ = false; }
// Push a new task onto the end of the queue
void addTask(std::function<void()> task) { tasks_.push_back(task); }
void run() {
// Reset the stopWork_ flag so that the worker doesn't immediately stop
stopWork_ = false;
// Only start a new worker thread if it isn't already running
if(!worker_.joinable()) worker_ = std::thread(&EventLoop::processList, this);
}
// Stop executing tasks
void stop() { stopWork_ = true; }
// Clear the queue
void clearAll() {
// Make sure that the thread isn't running before we modify the queue
stop();
tasks_.clear();
}
// Wait for the worker thread to exit before letting the class destroy itself
~EventLoop() {
if(worker_.joinable()) worker_.join();
}
private:
// Function to be run by the worker thread
void processList() {
//...
}
// Task queue to be processed by the worker thread
std::vector<std::function<void()>> tasks_;
// Worker thread
std::thread worker_;
// Communicates to the worker thread that it should stop working when set to true
bool stopWork_;
};
int main() {
//...
return 0;
}
In this initial C++ code stub for the program we're writing, the basic foundation of the EventLoop
class is put forward. The only new syntax here is present when we set up the worker thread:
std::thread(&EventLoop::processList, this);
worker_
gets its code from the processList()
member function, and special syntax is required for passing a member function into the std::thread
constructor. The Stack Overflow post linked to contains a technical answer for why this syntax is required, but it basically boils down to the following. Because there is a possibility that the member function modifies or makes use of other member objects in the class, which does happen to be the case for us, we need to specify which instance of the EventLoop
class the processList
function we want to be calling is in.
Thus, we take the pointer (&
) to the processList
function contained in the EventLoop
class, for which the namespace operator (::
) is used.
The this
parameter ensures the worker thread access to the member objects it needs to run, namely tasks_
and stopWork_
by passing in a pointer to the current class. It also serves the purpose of identifying which instance of the EventLoop
class we want to be using.
Next, let's fill in the empty space we left for the processList
function.
void processList() {
// Loop while there are elements in the vector and we aren't supposed to stop
while (tasks_.size() != 0 && !stopWork_) {
// Call the first task in the vector
tasks_[0]();
// Erase the first task in the vector (.begin() points to the first element of the vector)
tasks_.erase(tasks_.begin());
}
}
This is a pretty simple function. It loops through every item in the tasks_
vector (by erasing the first element each iteration, thus removing already processed tasks from the queue)
And finally, let's write the main()
function. Here, let's write a simple test of the EventLoop
class, testing every function.
// Add this on top:
#include <chrono>
#include <iostream>
int main() {
EventLoop el;
// Add 5 elements to the queue
for (int i = 0; i < 5; i++) {
el.addTask([i]() { std::cout << i << std::endl; });
}
// Clear the elements we just added
el.clearAll();
// Add 5 more elements to the queue
for (int i = 5; i < 10; i++) {
el.addTask([i]() { std::cout << i << std::endl; });
}
// Run the event loop
el.run();
// Wait for a bit before stopping the event loop
using namespace std::chrono_literals;
std::this_thread::sleep_for(100ms);
el.stop();
return 0;
}
This too is a pretty simple function, just calling each of the functions in the class to test their functionality. The only new part is the code to wait for 100 milliseconds.
using namespace std::chrono_literals;
std::this_thread::sleep_for(100ms);
std::this_thread
, provided by the thread
header, gives us a way to pause execution (called blocking) of the current thread for the given duration, in this case, 100 milliseconds.
The using namespace std::chrono_literals
line allows us to write 100 milliseconds as 100ms
instead of what it stands for: std::chrono::milliseconds(100)
. This is because the std::chrono_literals
namespace defines [INTEGER]ms
to be equivalent to std::chrono::milliseconds(100)
. This handy shortcut is really nice, especially considering that other literals like it are made available in the same namespace. (us
(microsecond), ns
(nanosecond), s
, min
, h
)
$ g++ test.cc -pthread
5
6
7
8
9
$
Again, note that this is only one of the many possible outputs. Depending on many other uncontrollable factors on the computer while the program is running, some of the outputs may be omitted because they weren't able to be run by the worker thread in the 100ms that the main thread allowed before stopping the event loop.
And thus, our example is completed!