C to C++: 5 Tips for Refactoring C Code into C++
In our C to C++ series, we’ve been discussing the fundamental details that embedded developers and teams need to transition from using C to C++. In our last post, C to C++: Bridging the Gap from C Structures to Classes, we discussed the differences between C and C++ structures and objects. Many teams interested in using C++ don’t need to jump off the deep end into metaprogramming and other complex C++ language features. Instead, they can use C++ as a better C. Today’s post will cover five simple tips to refactor C code into C++ that will dramatically improve your code base and not require a Ph.D. in C++.
Quick Links
- Part 1: C to C++: 3 Reasons to Migrate
- Part 2: C to C++: 3 Proven Techniques for Embedded Systems Transformation
- Part 3: C to C++: Bridging the Gap from C Structures to Classes
- Part 4: C to C++: 5 Tips for Refactoring C Code into C++
- Part 5: C to C++: Using Abstract Interfaces to Create Hardware Abstraction Layers (HAL)
- Part 6: C to C++: Templates and Generics – Supercharging Type Flexibility
Tip #1 – Convert #define to constexpr and const
If you’ve spent time writing C code, you are undoubtedly familiar with macros defined by using the preprocessor directive #define. The preprocessor, before compilation, will replace any code references to the macro identifier with the macro body through substitution. While convenient, using # defines macros can lead to several issues in C++ due to their text-based nature, lack of type safety, and potential for unintended side effects.
Here's why you should avoid #define and replace them with constexpr or const:
Type Safety - #define macros lack type safety since they perform textual substitution and don't respect C++ scoping rules. This can lead to unexpected behavior and subtle bugs.
Debugging Difficulties - When using #define, the preprocessor performs direct text replacement before the compiler sees the code, making debugging harder since the actual code might differ from what you see in the source.
Namespaces - #define macros are not within the scope of C++ namespaces, which can cause naming collisions and hinder code organization.
Compile-Time Computation - constexpr allows you to perform computations at compile time, providing better optimization opportunities and performance than runtime calculations done by #define macros.
Strongly Typed Constants - Using const or constexpr, you benefit from C++'s type safety, making your code more reliable and easier to understand.
Converting a #define macro to constexpr or const is a relatively straightforward process. For example, let’s say that you have the macro defined:
#define MAX_VALUE (100)
You can convert the above macro into a C++ const by adding the const keyword, defining the desired type, and providing the fixed value for the constant as follows:
const int MAX_VALUE = 100;
If you happen to have a function like macros defined, then instead, you can use constexpr. For example, let’s say you have a function like macro SQUARE as defined below:
#define SQUARE(x) ((x) * (x))
You can convert SQUARE into a constant expression by adding constexpr, defining the type, and writing the body of the expression as shown below:
constexpr int SQUARE(int x) { return x * x; }
Converting your #define macros into const and constexpr is a great way to improve type safety, leverage compile-time computation, and improve your code.
Tip #2 – Use Namespaces
One of the big problems with the C programming language is that everything is thrown into a single namespace. C programmers often try to simulate a namespace by adding a prefix to variables and functions. For example, if you have functions that are associated with an RTOS, you’ll often find them prefixed with something like Rtos_ as follows:
void Rtos_TaskCreate(); void Rtos_MutexCreate(); void Rtos_SemaphoreCreate(); void Rtos_EventFlagCreate();
The prefixing attempts to create something like a namespace so that function and variable names don’t conflict. In C++, we can create separate namespaces to organize our code! For example, the same RTOS example as above might be written in C++ as follows:
namespace Rtos { TaskCreate(); MutexCreate(); SemaphoreCreate(); EventFlagCreate(); }
Using different RTOS or thread mechanisms, I could have a namespace for each. For example, one namespace is used for FreeRTOS, one for ThreadX, and one for pthread. They might have some of the same definitions but won’t conflict because they are in different namespaces.
Namespaces offer many benefits and can do a lot for you. Below is a quick list of what namespaces accomplish for you:
- Organizes related code elements into a single logical grouping
- Helps to avoid naming conflicts between different parts of a program or between different libraries
- Allows for more concise and readable code by reducing the need for long, descriptive names
- Can be used to selectively import only the elements you need from a library rather than importing everything
- Enables the creation of multiple versions of a library with different namespaces to avoid conflicts between incompatible versions
- Provides a way to resolve ambiguity between functions or classes with the same name, but different namespaces
- Makes it easier to understand the context in which a particular function or variable is defined by providing a clear namespace hierarchy.
When you want to use a namespace in your code, all you need to do is include it with the syntax:
using namespace NAMESPACE_NAME
For example, to use the Rtos namespace, you might have code like the following:
using namespace Rtos; . . . Rtos::TaskCreate(); Rtos::MutexCreate(); PX5::TaskCreate();
Using namespaces can help you organize your code and get it into “better shape”.
Tip #3 - Replace C-style Pointers with Smart Pointers and References
One of the great benefits of the C programming language is that it provides developers with the tools necessary to work at the hardware level. You can create pointers to memory that allow you to set registers, read registers, create stacks, and all sorts of low-level features necessary for embedded systems. As you probably know, pointers, while powerful, are also a significant issue if they are not treated carefully. You can open yourself up to some of the nastiest bugs or create a security vulnerability that may not be discovered for years!
When moving code from C to C++, it’s a good idea to identify where you are using pointers and convert them to C++ smart pointers. A smart pointer (unique_ptr, shared_ptr, weak_ptr) is a class template that provides automatic memory management for dynamically allocated objects. Smart pointers are an essential feature of the C++ Standard Library (introduced in C++11) and are designed to improve memory safety and manage the lifetime of objects allocated on the heap.
The primary purpose of smart pointers is to eliminate manual memory management issues like memory leaks and dangling pointers, which can be common sources of bugs in C++ programs. Smart pointers achieve this by taking ownership of the dynamically allocated objects and automatically releasing the memory when it is no longer needed.
There are three types of smart pointers provided by the C++ Standard Library:
- std::unique_ptr - This smart pointer allows single ownership of the dynamically allocated object. It ensures that only one std::unique_ptr can point to a particular object anytime. When the std::unique_ptr goes out of scope or is explicitly reset, it automatically deletes the object it points to.
- std::shared_ptr - This smart pointer allows multiple shared ownership of the same dynamically allocated object. It maintains a reference count internally, and as long as there is at least one std::shared_ptr pointing to the object, it remains alive. When the last std::shared_ptr pointing to the object is destroyed or reset, the object's memory is deallocated.
- std::weak_ptr - This smart pointer is used in conjunction with std::shared_ptr to break cyclic dependencies and avoid memory leaks. A std::weak_ptr does not increase the reference count, so it does not prevent the object from being deleted when the last std::shared_ptr is destroyed. It allows you to check if the object still exists before accessing it.
You might be thinking, “Jacob, I don’t use dynamic memory allocation, so smart pointers are useless to me!”. If your biggest use of pointers is to pass parameters, then you may be better off changing the pointer to a reference. In C++, references provide a safer and more convenient way to pass parameters to functions without the risks associated with raw pointers. Using references can avoid common issues like null pointer dereferencing and unintentional memory manipulation. You can also avoid pointer notation within the function, making your code cleaner and easier to read. Let’s look at an example.
Suppose I have a function that takes a pointer to an array:
void processData(int (*array)[10]) { for (int i = 0; i < 10; ++i) { (*array)[i] *= 2; } }
We can convert this function to use references instead of pointers by converting the pointer to a reference instead as follows:
void processData(int (&array)[10]) { for (int i = 0; i < 10; ++i) { array[i] *= 2; } }
A significant advantage to moving to C++ is improved safety and security, and smart pointers and references can help you improve your code to get there. It’s relatively low-hanging fruit too!
Tip #4 – Replace C-Style Casts with C++ Style Cast Operators
C-style casting, also known as "old-style" casting, involves using traditional C-style casting operators like (type) to perform the conversion. However, C++ provides safer and more explicit casting mechanisms known as C++-style casts. These C++-style casting operators are static_cast, dynamic_cast, const_cast and reinterpret_cast. Let’s quickly take a look at what each of these does:
static_cast - This is the most commonly used C++-style cast. It is used for conversions that are known to be safe and well-defined, such as numeric conversions, upcasts in inheritance hierarchies, and explicit type conversions. For example, the following code shows how static_cast can be used to perform a numeric conversion:
double myDouble = 3.14; int myInt = static_cast<int>(myDouble);
dynamic_cast - This is used for safe casts between pointers or references of polymorphic classes (i.e., classes with virtual functions). It performs run-time type checking to ensure that the cast is valid. If the cast cannot be performed, dynamic_cast returns a null pointer (for pointers) or throws an exception (std::bad_cast) for references.
const_cast – This adds or removes const or volatile qualifiers from a variable. It provides a way to temporarily modify the constness or volatility of an object, allowing for certain operations that the const qualifier would otherwise prevent.
The const_cast operator is often used to work around situations where a function or method has both const and non-const overloads, and you need to call the non-const version on a const object.
Remember that using const_cast to remove constness and then modify the object might lead to undefined behavior if the object was originally declared const for a good reason. Therefore, you should be careful when using const_cast and ensure that the modifications are safe and won't lead to unexpected behavior.
reinterpret_cast - This is the most powerful but potentially dangerous C++-style cast. It allows you to reinterpret the binary representation of one type as another, regardless of their actual relationship. It is typically used for low-level operations like casting between unrelated pointers or converting pointers to integer types. You might use this type of cast for memory-mapped peripheral drivers.
Tip #5 – Add Unit Tests
I know I’m straying a bit from C++ language features to use when refactoring your C code into C++. However, I feel that this is an important tip. If you have C code you plan to convert to C++, it’s a good idea to develop unit tests to verify the code's behavior after the conversion.
Unit tests allow us to verify the behavior of a function for a wide range of inputs, ensuring we get the expected output. We can use the unit tests within a CI/CD pipeline for regression tests to ensure that everything works as expected. While unit tests certainly aren’t required, I think you’ll find that adding them will help to ensure that your refactored, converted C code works more reliably in C++.
Conclusions
When you adopt C++ for your embedded products, I think you’ll find existing C code that you want to refactor and convert to C++. In this post, we’ve looked at five tips for low-hanging fruit to help you start to convert any existing code. We’ve discussed a few ideas, and there are certainly many more. For example, you may want to convert your initializations to uniform initialization, break dependencies with interfaces, and leverage static_assert.
Now that we’ve discussed some practical refactoring concepts, the next post will look a bit closer at classes as we work toward discussing how to write useful hardware abstraction layers.
- Comments
- Write a Comment Select to add a comment
Very nice article!
A note on using namespaces: to call the qualified function Rtos::TaskCreate(), one only needs to #include the header where it is defined. Adding "using namespace Rtos;" would allow calling TaskCreate() without the Rtos:: qualification.
Namespace aliasing and "using" declarations have been a pitfall for some. There is a good note from Google regarding the risks and safer usage.
You left an important one and ended up doing things the hard way. Use auto. It's been around since C++11 and provides a lot of safety and flexibility.
auto i = 10; /*-OR-*/ auto i = int{10}; //< if intent is necessary
auto myInt = static_cast<int>(myDouble);
auto myPtr = std::make_unique<myStruct>();
auto shared = std::make_shared<myStruct();
auto result = myFunc(arg); // Gets the correct type every time
Lots of respected C++ experts highly recommend this.
Yes, some don't like it, but they have not looked at it carefully. It's a knee-jerk reaction to something new.
Thanks for the comment.
auto is definitely a good suggestion and one of my top 10 for sure. It just didn't make the top 5! (I think I wrote about auto in a different blog a while back, I just can't recall which one . . .)
Thanks for sharing and adding the example for other readers to see as well!
Regarding unit test, I think you missed an important point here.
It is way easier to run unit test on most C++ code, or in other word easier to write unit-test friendly code.
Because you can encapsulate persistent variables together with the methods using it.
Short version: In C to get a clean test environment you need to create a new process, in C++ you just create a new instance of the class.
Longer version:
When you advance a bit past the schoolbook example of unit testing a simple function like
int addTwoNumbers( int a, int b )
You start facing side effects, very often a function refers or changes a value/state stored outside the function. In bad cases in public variables. In better cases in a 'file static' variable.
Very simple example in C:
static int state; void State_Update( void ) { state = state < 10)? state + 1 : 0; } int State_Get( void ) { return state; }
If I want to write a unit test for this, I have a problem, the 'state' variable is initialized by the C-init code during process creation. Thus if I want to get that to a known state before a step in the unit test, I am left with three options
- Create a new process for each step that needs the variable 'reset'
- This makes the unit test slower.
- Create a `set` function to the code that I am testing.
- More code to maintain
- Mixing unit test and production code.
- It is a pure manually task to ensure we remember to reset all the refereed variables.
- Make it a global variable.
- GEEZZZ Let's not go there.
A fourth option of course is to pull in an expensive unit test tool to keep track of those refereed variables.
Taking option 2. a unit test would look similar to this:
#include "state.h" foo(){ //// Test 1 //// // reset state module State_Set(0); // Hope i remembered them all. // init value Test( "Check init value", State_Get() == 0 ); // Value incremented by update State_Update(); Test( "Check updated value", State_Get() == 1 ); //// Some more tests /// ...... //// Test 10 //// // reset state module State_Set(0); // Hope i remembered them all. // Value wraps after 10 updates for( int i = 0; i<10; i++){ State_Update(); } Test( "Check wrapped value", State_Get() == 0 ); }
Maybe this looks simple, but can you imagine the maintenance, and potential bugs when it is 5+ values that has to be reset, and have a setter function....
Converting this to c++ makes life easier.
The same very simple example in C++:
class State { private: int state; public: State() { state=0; } void Update( void ) { state = state < 10)? state + 1 : 0; } int Get( void ) { return state; } }
Now a unit test would look similar to this:
#include "state" foo(){ // obejct to run tests on State dut; //// Test 1 //// // reset dut = State() // init value Test( "Check init value", State.Get() == 0 ); // Value incremented by update State.Update(); Test( "Check updated value", State.Get() == 1 ); //// Some more tests /// ...... //// Test 10 //// // reset dut = State(); // Value wraps after 10 updates for( int i = 0; i<10; i++){ State.Update(); } Test( "Check wrapped value", State.Get() == 0 ); }
Note how the test has no knowledge of any internal (private) variables, but is still sure to reset them all.
For completion another style to do this would be to use the new operator
State* dut; // Test 1 dut = new State(); dut.update() .... delete dut; // Test 2 dut = new State(); ....
Thanks for the comment. I definitely agree that the unit tests can be cleaner and take advantage of C++. I've written a fair about of unit tests in C and it definitely takes carefully crafting your code to avoid the problems you've mentioned.
Thanks for sharing!
To post reply to a comment, click on the 'reply' button attached to each comment. To post a new comment (not a reply to a comment) check out the 'Write a Comment' tab at the top of the comments.
Please login (on the right) if you already have an account on this platform.
Otherwise, please use this form to register (free) an join one of the largest online community for Electrical/Embedded/DSP/FPGA/ML engineers: