The test-driven method for learning C++
Learning from books or structured courses is only one method; the other one is through personal exploration. Imagine learning C++, but instead of having to look through a bunch of code examples first, write the code as you think it should work and learn incrementally the differences between your intuition and the actual language. In fact, people naturally combine these two methods even when going through a structured learning course.
One downside of learning through exploration is that it’s hard to understand your progress, and you might often end up in difficult spots. A method comes to the rescue: TDD.
TDD is a counter-intuitive, effective method for incremental design. Its simplest description is the following:
- Step 1, also known as red: Write one test that fails and shows the next case that needs to be implemented
- Step 2, also known as green: Write the simplest code to make the test pass (and keep all the other tests passing)
- Step 3, also known as refactor: Refactor the production code and the test code to simplify.
This red-green-refactor cycle repeats in very small cycles (often 5-10 minutes) until all the behaviors associated with the current feature or user story have been implemented.
Addressing TDD misconceptions
Personally, I am a fan of TDD, and I’ve used it for more than 10 years with a lot of success. In fact, I used TDD to write the sample code for this book. However, I know that TDD has been received with mixed feelings by the industry. Part of it is a failure in imagination, a common question being: How can I write a test for a method that doesn’t exist? Well, pretty much the same way in which you write code that hasn’t existed before: you imagine it’s there and focus on the desired inputs and outputs. Other criticism comes from failing to understand what TDD really is and how it works. Examples of faux TDD failures often involve starting with edge cases and showing that things get complicated very quickly when you should start with happy-path cases. Claims of TDD slowing down development are credible, but the truth is that this method helps us be more thorough and calculated, thus avoiding issues that are usually caught much later in the process and fixed with much sweat and stress. Finally, TDD is not a method for designing high-performing algorithms, but it can help you find a first solution that you later optimize with the help of a test suite.
To understand how to learn a programming language with a modified TDD cycle, we need to clarify two things about TDD. First, TDD is counter-intuitive because it requires a prolonged focus on the problem domain, while most programming courses teach us how to deal with the solution domain. Second, TDD is a method for incremental design; that is, finding a code structure that solves a specific problem in a step-by-step manner instead of all at once. These two characteristics make TDD the best fit for learning a new programming language, with some support.
Imagine that instead of learning the whole thing about C++ before being able to run a program, you just learn how to write a test. That is easy enough because tests tend to use a small subset of the language. Moreover, running the tests gives you instant feedback: failure or red when something is not right and success or green when everything is working fine. Finally, this allows you to explore a problem once you have one or more tests and figure out how to write the code such that the compiler understands it – which is what you want when you learn a language. It might be a bit problematic to figure out the error messages, particularly in C++, but if you have a person (or maybe an AI in the future) to ask for help, you’ll learn a lot on your way and see the green bar whenever you’ve learned something new.
This method has been tested on a small scale, and it worked remarkably well. Here’s how a learning session might work for C++.
Setup
At least two actors are involved in the learning process; we’ll call them the coach and the student. I prefer using a coach instead of the instructor because the goal is to guide the students on their own learning path rather than teach them things directly.
I will discuss the rest of the session as if only a student is involved. A similar setup can work with multiple students as well.
The first thing the actors need to do is to set a goal. Typically, the goal is to learn a minimum of C++, but it can also be learning more about a specific topic – for example, std::vector
or STL algorithms.
In terms of the technical setup, this process works best with the two people watching the code on the same monitor and working side by side. While this is best done in person, remote is possible as well through various tools.
To start, the coach needs to set up a simple project composed of a test library, a production code file, and a test file. A simple way to run the tests needs to be provided, either as a button click, a keyboard shortcut, or a simple command. The setup I recommend for C++ is to use doctest
(https://github.com/doctest/doctest), a header file-only test library that is very fast and supports a lot of the features needed for production.
Here’s the simplest structure for this project:
- A test file,
test.cpp
- A production header file,
prod.h
- A
doctest.h
file - A Makefile allowing us to run the tests
A production cpp
file may also be needed depending on the learning objectives.
The coach also needs to provide an example of a first test that fails and show how to run the tests. The student takes over the keyboard and runs the test as well. This test can be very simple, as in the following example:
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN #include "doctest.h" #include "prod.h" TEST_CASE("Test Example"){ auto anAnswer = answer(); CHECK(anAnswer); }
The production header shows the following:
bool answer(){ return true; }
The first order of business is then to make the test pass. The question the coach will keep asking the student is: “How do you think this will work? Write whatever you find intuitive.” If the student finds the correct answer, great! If not, show the correct answer and explain the reasons.
This example is very useful because it introduces a few elements of the language and shows them working: a function declaration, a variable, a test, and a return value. At the same time, the process is very nice because it gives the student a measure of progress: tests passing is good, and tests not passing means there’s something to learn.
With all these done, it’s time to enter the exploration phase.
Exploring the language
There are two ways to explore a programming language in this manner: through simple problems that introduce concepts one by one, also known as koans, or through solving a more complex problem.
Either way, the method stays the same: first, the coach writes a simple test or helps the student write a simple test that fails. Then, the student is asked to write the solution that seems most intuitive to them. Tests are run, and if they don’t pass, the coach needs to explain what is not working. Either the coach or the student makes the change, and when the tests pass, the step ends with clear progress.
During this process, it’s important to focus on the next natural step for the student. If the student has specific questions or curiosities, the next test can treat these instead of going through a scripted process. This adaptive way of learning helps students feel in charge, and the process gives them an illusion of autonomy that eventually turns into reality.
What about memory issues?
We spent some time in this chapter discussing the fact that C++ programmers need to learn more about memory management than their colleagues using other mainstream programming languages. How can they learn memory management with this method? Tests will not catch memory issues, will they?
Indeed, we want students to learn that they need to care about memory from the very beginning. Therefore, memory checks need to be integrated into our test suite. We have two options to do this: either use a specialized tool or select a test library that can detect memory issues.
A specialized tool such as valgrind
is easy to integrate into our process. See the following example of a Makefile:
check-leaks: test valgrind -q --leak-check=full ./out/tests test: test.cpp ./out/tests test.cpp: .FORCE mkdir -p out/ g++ -std=c++20 -I"src/" "test.cpp" -o out/tests .FORCE:
The test.cpp
target is compiling the tests. The test target depends on test.cpp
and runs the tests. And the first target, check-leaks
, runs valgrind
automatically with options to show errors only when they come up so that students don’t get overwhelmed. When running make
without any parameters, the first target is picked, so the memory analysis is done by default.
Assume we are running the tests with a memory leak, as in the following example:
bool answer(){ int* a = new int(4); return true; }
We are immediately greeted by this output:
==========================================================[doctest] test cases: 1 | 1 passed | 0 failed | 0 skipped [doctest] assertions: 1 | 1 passed | 0 failed | [doctest] Status: SUCCESS! valgrind -q --leak-check=full ./out/tests [doctest] doctest version is "2.4.11" [doctest] run with "--help" for options ==========================================================[doctest] test cases: 1 | 1 passed | 0 failed | 0 skipped [doctest] assertions: 1 | 1 passed | 0 failed | [doctest] Status: SUCCESS! ==48400== 4 bytes in 1 blocks are definitely lost in loss record 1 of 1 ==48400== at 0x4849013: operator new(unsigned long) ==48400== by 0x124DC9: answer()
This output provides enough information for a conversation with the student.
The second option is to use a test library that already has memory leak detection implemented. CppUTest (http://cpputest.github.io/) is such a library, and it also has the advantage of supporting C and working for embedded code.
With these tools at our disposal, it’s now clear that this method works for teaching C++ to anyone who wants to try it or to dive deeper into specific parts, using exploration as a method.
Now that we learned two methods for learning C++ today, let’s go back to understanding what C++’s niche is and why it necessarily needs to be more complex than other languages.