Difference between revisions of "Developing modules in C++ (basic)"
m (→Composition vs inline declaration: - typo) |
m (→Resource Acquisition Is Initialization: const oapi::Pen * wouldn't work) |
||
(8 intermediate revisions by 3 users not shown) | |||
Line 26: | Line 26: | ||
==Example utility class== | ==Example utility class== | ||
− | This class is a one that is used in many places in LaunchMFD. The MFD has three modes: Standard, Compass and Direct Ascent. Each mode is an encapsulated class, that contains the Pens class (AKA Composition). Whenever a mode is destructed, the Pens object is automatically destructed along with it, deallocating its resources. Because not all modes use all LineStyles, there is a certain overhead of allocating additional pens. But guess what? You shouldn't care about it, because the construction and destruction of the Pens is performed so | + | This class is a one that is used in many places in LaunchMFD. The MFD has three modes: Standard, Compass and Direct Ascent. Each mode is an encapsulated class, that contains the Pens class (AKA Composition). Whenever a mode is destructed, the Pens object is automatically destructed along with it, deallocating its resources. Because not all modes use all LineStyles, there is a certain overhead of allocating additional pens. But guess what? You shouldn't care about it, because the construction and destruction of the Pens is performed so rarely, that it's much better to wait a negligible amount of time while having a readable and easy to maintain general purpose class, than doing the same thing again and in different places, like manually allocating and deallocating individual pens. What if you forget to perform a pen's deallocation, while you allocate it on each MFD's Update() call?. The Pens class has a simple interface, that consists of a pens getter, which is what you should be aiming for, and additionally, an empty default constructor that does the job of pens allocation behind the scenes. |
Declaration: | Declaration: | ||
Line 67: | Line 67: | ||
Pens & operator=(const Pens & other); | Pens & operator=(const Pens & other); | ||
− | const int cNumPens; // keep this number in order by counting the number of LineStyle colors | + | // Adds a pen with bounds checking |
− | MyPEN | + | void AddPen( LineStyle style, MyPEN pen ); |
+ | |||
+ | const static int cNumPens = 6; // keep this number in order by counting the number of LineStyle colors | ||
+ | MyPEN m_pens[cNumPens]; // an array of pens. | ||
}; | }; | ||
Line 80: | Line 83: | ||
Pens::Pens() | Pens::Pens() | ||
− | |||
{ | { | ||
− | |||
− | |||
// Allocate pens themselves | // Allocate pens themselves | ||
− | + | AddPen( Green, oapiCreatePen(1, 1, GREEN) ); | |
− | + | AddPen( Yellow, oapiCreatePen(1, 1, YELLOW) ); | |
− | + | AddPen( GreenDashed, oapiCreatePen(2, 1, GREEN) ); | |
− | + | AddPen( White, oapiCreatePen(1, 1, WHITE) ); | |
− | + | AddPen( Grey, oapiCreatePen(1, 1, GREY) ); | |
− | + | AddPen( Red, oapiCreatePen(1, 1, RED) ); | |
} | } | ||
Line 99: | Line 99: | ||
// Deallocate pens | // Deallocate pens | ||
for(int i = 0; i < cNumPens; ++i) | for(int i = 0; i < cNumPens; ++i) | ||
− | + | oapiReleasePen(m_pens[i]); | |
− | oapiReleasePen( | ||
− | |||
− | |||
} | } | ||
Line 113: | Line 110: | ||
throw std::invalid_argument("Pens::GetPen(): out of bounds"); | throw std::invalid_argument("Pens::GetPen(): out of bounds"); | ||
} | } | ||
− | return | + | return m_pens[style]; |
+ | } | ||
+ | |||
+ | void Pens::AddPen( LineStyle style, MyPEN pen ) | ||
+ | { | ||
+ | if ( style >= cNumPens ) | ||
+ | throw std::invalid_argument("Pens::AddPen(): out of bounds"); | ||
+ | m_pens[style] = pen; | ||
} | } | ||
</pre> | </pre> | ||
Line 210: | Line 214: | ||
virtual ~RAIIPen(); | virtual ~RAIIPen(); | ||
− | + | oapi::Pen * GetPen() const; | |
protected: | protected: | ||
private: | private: | ||
Line 256: | Line 260: | ||
} | } | ||
− | + | oapi::Pen * RAIIPen::GetPen() const | |
{ | { | ||
return pen; | return pen; | ||
Line 296: | Line 300: | ||
class GeneralShipData | class GeneralShipData | ||
{ | { | ||
− | + | public: | |
− | + | // these parameters can't be modified by outside classes | |
− | + | const MovementParameters & GetMovParams() const { return m_movParams; } | |
− | + | void Update() // This can be called only by owners of non-const reference to object of this class | |
− | + | { | |
− | + | double dt = 1; | |
− | + | m_movParams.Update( dt ); | |
− | + | } | |
− | |||
− | + | private: | |
− | + | MovementParameters m_movParams; // the member of interest | |
+ | // ... | ||
+ | // other objects | ||
+ | // ... | ||
}; | }; | ||
Line 315: | Line 321: | ||
} | } | ||
− | void GraphicsTeam( const GeneralShipData & gsd ) | + | void GraphicsTeam( const GeneralShipData & gsd ) // Enforcing a conversion to a const reference |
{ | { | ||
// gsd.Update(); // sabotage by graphical team unsuccessful - compilation error | // gsd.Update(); // sabotage by graphical team unsuccessful - compilation error | ||
− | const MovementParameters & movPar = gsd.GetMovParams(); // but I can still display graphics! | + | const MovementParameters & movPar = gsd.GetMovParams(); // This is all I can do |
+ | // movPar.x = 3; // Missed again - compilation error | ||
+ | // but I can still display graphics! | ||
std::cout << "x = " << movPar.x << ", y = " << movPar.y << std::endl; | std::cout << "x = " << movPar.x << ", y = " << movPar.y << std::endl; | ||
} | } | ||
Line 325: | Line 333: | ||
{ | { | ||
GeneralShipData gsd; | GeneralShipData gsd; | ||
− | PhysicsTeam( gsd ); | + | for (int i = 0; i < 10; ++i) |
− | + | { | |
+ | PhysicsTeam( gsd ); | ||
+ | GraphicsTeam( gsd ); | ||
+ | } | ||
return 0; | return 0; | ||
} | } | ||
</pre> | </pre> | ||
+ | |||
+ | ==Further reading== | ||
+ | * [http://www.htdp.org/ How to design programs], MIT Press : While based on Scheme as programming language and pretty old, it describes many architecture aspects. | ||
+ | * [http://mindprod.com/jgloss/unmain.html How to write unmaintainable code] : Educative satire on the question, how to make your development harder than necessary. | ||
+ | |||
{{Stub}} | {{Stub}} |
Latest revision as of 10:54, 12 August 2012
This article explains a few basic concepts about planning add-on modules in C++. This article is not recommended for readers who have no experience yet in C++.
What this article is not about[edit]
It is not goal to describe the C++ language or the standard libraries of C++. It also is not about the OrbiterSDK. Goal is to explain more advanced topics about object-orientation and software architecture. It is no law, no set of rules that have to be followed at all costs. You need a blob class? It could be useful maybe.
But always remember: C++ does only do, what you tell C++ to do.
Classes and objects[edit]
The most important capability of C++ is called object-orientation. It simply means, that instead of having data (variables, constants) and instructions functionally separated, both concepts are joined into classes and objects of such classes. An object generally contains logically connected data and functions (here: methods) for manipulating this contained data. One other important is the ability to inherit traits from a superclass to a derived class and overload functions of this superclass with your own versions of the function. This ability is used in many different ways, mostly for generic, abstract programming and code reuse.
Interface classes[edit]
An interface class is essentially a class, that only defines the signatures of its functions. It does not implement these functions. Since any class in C++ can inherit from multiple superclasses, it can also implement multiple interfaces at once. And not only that, you can also join multiple interfaces in one more specialized interface.
Such an interface gets very useful, when you want to use classes in your functions, without having to support the whole hierarchy of classes for one feature. Instead, you just declare that you only want a class that implements such an interface to work with.
For example, you could define an interface for all subsystem classes, that can save their state in the scenario file.
Practical advices for designing classes[edit]
A very good rule to follow is that the classes should be as small as possible, and responsible for one thing only, ie. ideally, a class should accept configuration input through constructor(s) that completely defines its behaviour (without the need of setting additional parameters via methods, except maybe methods that let add objects in a polymorphic way), and provide one or just a few methods, which provide access to the data that the class processed (it's the class' interface, just as buttons in an elevator). The user of the class should not be bothered about the internals of the class, just as the elevator's user doesn't need to know about the way the elevator's engine functions, only about the buttons. This is called encapsulation, and it helps in focusing on getting new things done, instead of being distracted by reviewing the same code many times. If something goes wrong in the system that the class is responsible for (elevator engine's malfunctions in some cases), you will know that you only need to look into the one class' implementation, and not any other class (other code), that would distract you. Keeping classes small greatly increases probability of reusing these classes in other places of the system that you're designing, or even other projects, so that is makes sense to create a library of such reusable code. Whenever you catch yourself on repeating some portion of code in your project, you should treat it as a design error, and should organize such code into functions as an optional first step, and then into self-contained, encapsulated classes with their own set of variables and methods. Many designers also advice to compose small, low-level classes into higher and higher level classes, instead of building a hierarchy of classes, using inheritance. Inheritance should be only used if there's a chance that a code can be called in a polymorphic way, like two types of guidance algorithms having a common interface - guidance algorithm (inheritance vs composition). Anyway, always keeping classes small allows you make a final decision later in the process of design and implementation.
Example utility class[edit]
This class is a one that is used in many places in LaunchMFD. The MFD has three modes: Standard, Compass and Direct Ascent. Each mode is an encapsulated class, that contains the Pens class (AKA Composition). Whenever a mode is destructed, the Pens object is automatically destructed along with it, deallocating its resources. Because not all modes use all LineStyles, there is a certain overhead of allocating additional pens. But guess what? You shouldn't care about it, because the construction and destruction of the Pens is performed so rarely, that it's much better to wait a negligible amount of time while having a readable and easy to maintain general purpose class, than doing the same thing again and in different places, like manually allocating and deallocating individual pens. What if you forget to perform a pen's deallocation, while you allocate it on each MFD's Update() call?. The Pens class has a simple interface, that consists of a pens getter, which is what you should be aiming for, and additionally, an empty default constructor that does the job of pens allocation behind the scenes.
Declaration:
#ifndef PENS_H #define PENS_H #include <orbitersdk.h> class Pens { public: Pens(); virtual ~Pens(); // Use this enum select the pen that you want with a call like: // Pens::GetPen( Pens::Green ); enum LineStyle { Green, Yellow, GreenDashed, White, Grey, Red }; // From now on, type MyPEN means a pointer to Pen in namespace oapi typedef oapi::Pen * MyPEN; // Class' interface - returns a constant reference to MyPEN // You can select the pen you want from the LineStyle const MyPEN & GetPen(LineStyle style) const; protected: private: // Declaring copy constructor and assignment operator private makes the class non-copyable // This takes away the pain of manual memory management. If you need the class again, // just declare it again. Pens(const Pens & other); Pens & operator=(const Pens & other); // Adds a pen with bounds checking void AddPen( LineStyle style, MyPEN pen ); const static int cNumPens = 6; // keep this number in order by counting the number of LineStyle colors MyPEN m_pens[cNumPens]; // an array of pens. }; #endif // PENS_H
Implementation:
#include "Pens.h" #include <stdexcept> Pens::Pens() { // Allocate pens themselves AddPen( Green, oapiCreatePen(1, 1, GREEN) ); AddPen( Yellow, oapiCreatePen(1, 1, YELLOW) ); AddPen( GreenDashed, oapiCreatePen(2, 1, GREEN) ); AddPen( White, oapiCreatePen(1, 1, WHITE) ); AddPen( Grey, oapiCreatePen(1, 1, GREY) ); AddPen( Red, oapiCreatePen(1, 1, RED) ); } // Destructor // Guaranteed to be executed when class leaves scope Pens::~Pens() { // Deallocate pens for(int i = 0; i < cNumPens; ++i) oapiReleasePen(m_pens[i]); } const MyPEN & Pens::GetPen(LineStyle style) const { if ( style >= cNumPens ) { // Whoops! You forgot to increment cNumPens after adding a new LineStyle. // Must halt the application. throw std::invalid_argument("Pens::GetPen(): out of bounds"); } return m_pens[style]; } void Pens::AddPen( LineStyle style, MyPEN pen ) { if ( style >= cNumPens ) throw std::invalid_argument("Pens::AddPen(): out of bounds"); m_pens[style] = pen; }
Memory management in C++[edit]
A very unique feature of C++ is the warranty that whenever class declared inside a scope (defined as '{' and '}') leaves this scope, its destructor is always called, allowing you to perform optional cleanups of your code. After the destructor has been called, the class is deallocated, freeing its memory. This feature lets your application handle memory, or generally: resources, in a predictable way, freeing them, when their user (the class) doesn't need them anymore. Therefore, if you encounter a situation where you have a double scope and need a class only in the second scope, declare it in the second scope, not at the beginning of the function, so that resources taken by the class are released as soon as possible, for example:
void SomeClass::SomeMethod() { // ... // UtilClass util; // wrong place to declare UtilClass; // ... { UtilClass util; // correct place to declare UtilClass util.DoSth(); // since we use it only in this scope } // Releasing util's resources by automatically calling its destructor // ... // ... } // Otherwise would release them possibly much later
Resource Acquisition Is Initialization[edit]
The destructor mechanism in C++ allows to facilitate RAII. In the example Pens class, you are guaranteed, that upon the class' declaration, all the resources are allocated and the class is instantly ready to use. On the other hand, when the class leaves the scope, so when you won't need it, its resources are guaranteed to be deallocated, and you don't need to do it by hand. In our Pens example, we were able to wrap the memory management inside a class. There are some situations when its impractical, like when initializing pointers, that don't need to be released, but following RAII still increases code readability. Consider the following counter-example:
void SomeClass::SomeMethod(OBJHANDLE planet, char * baseName) { OBJHANDLE base; // ... // Other C-like declarations. // ... { base = oapiGetBaseByName(planet, baseName); // Initializing base later. // ... // Do something with base. Is it initialized? Yes, but you have to double check it, // effectively distracting yourself from your main task // ... { // Get another base into the same variable, // theoretically releasing resources of the first OBJHANDLE, // but it's a poor trade-off of resource management vs code readability. base = oapiGetBaseByName(planet, "Cape Canaveral"); // ... // Do something with base, but is it the first base or the second base? You have to check it, distracting yourself again. // ... } // What if we needed the first base here again, and the variable points to the second base? } } // Releasing resources of the second base.
Proper example:
void SomeClass::SomeMethod(OBJHANDLE planet, char * baseName) { // ... { OBJHANDLE baseOuter = oapiGetBaseByName(planet, baseName); // Initializing upon declaration // ... // Do something with baseOuter. Is it initialised? No question, since we follow RAII. // ... { OBJHANDLE baseInner = oapiGetBaseByName(planet, "Cape Canaveral"); // Initializing upon declaration. Using a different variable name. // ... // Do something with baseInner. We're sure that it's initialized and that it isn't the outer base. // ... } // baseInner gets deallocated. // You can still do something with the baseOuter, since it hasn't left the scope. } // baseOuter gets deallocated. }
Following our Pens example, you could also stick more into RAII, and create a RAIIPen class, that wouldn't hold all typical pens you'd ever use, but create them individually on demand and deallocate them instantly, and not during MFD modes reconstruction. You would tell the class which pen you want through its constructor, by passing an enum, similarly to the Factory design pattern:
#ifndef RAIIPEN #define RAIIPEN #include <orbitersdk.h> class RAIIPen { public: enum LineStyle { Green, Yellow, GreenDashed, White, Grey, Red }; RAIIPen( LineStyle style ); virtual ~RAIIPen(); oapi::Pen * GetPen() const; protected: private: RAIIPen(const RAIIPen & other); RAIIPen & operator=(const RAIIPen & other); oapi::Pen * pen; // oapi Pen }; #endif
Implementation:
#include "RAIIPen.h" RAIIPen::RAIIPen( LineStyle style ) { pen = NULL; switch ( style ) { case Green: pen = oapiCreatePen(1, 1, GREEN); break; case Yellow: pen = oapiCreatePen(1, 1, YELLOW); break; case GreenDashed: pen = oapiCreatePen(2, 1, GREEN); break; case White: pen = oapiCreatePen(1, 1, WHITE); break; case Grey: pen = oapiCreatePen(1, 1, GREY); break; case Red: pen = oapiCreatePen(1, 1, RED); break; // A compiler warning will be generated if one defined colour isn't serviced. The warning shouldn't be ignored. } } RAIIPen::~RAIIPen() { oapiReleasePen(pen); // Deallocate pen } oapi::Pen * RAIIPen::GetPen() const { return pen; }
Composition vs inline declaration[edit]
Note that because there is no overhead, resulting from allocating potentially unused pens, only the RAIIPen approach promotes declaring a new pen object in a scope as deep as possible, instead of composing the Pens class in another, higher level class. However, because the resources are supposed to be created and released multiple times, because Update() method is called frequently, the above example may be considered over-zealous. Nevertheless, the exact impact on performance must be measured using a profiler, before making final and autoritative decisions, and never guessed, unless some really obvious performance mistake is made during allocation and deallocation. An obvious case when you absolutely must compose a class into another is when the component is supposed to hold state in a variable, like time passed after launch.
Const correctness[edit]
Const correctness (CC) deals with deliberate limitation of write access to certain classes or variables, providing a mechanism of locking these variables and exposing them as read only to other parts of your code or other team, that both aren't supposed to modify these data. Imagine a situation where you have a team of experienced physical core programmers, and possibly a team of inexperienced programmers of simple graphics. You would want the graphical team to only read the physical data for display, and never modify them, to avoid hard to track bugs. This can be achieved by passing the objects of interest as const references to original objects, or eventually as copies of these objects, if you can afford copying large chunks of data. The CC works in a recursive way, meaning that if you pass a const reference to a physics engine to the graphical team, they will only be able to access const methods of the engine, which can provide only const access to the engine components, be it other objects, or variables. Even if you program alone, using CC you can make your application more rock solid, and less error prone. Of course, the physics engine should be able to provide read/write access to its components via other, non-const methods, which can be called only from classes which receive a non-const reference to physics engine.
Getters & setters[edit]
When designing encapsulated classes, whose variables are accessed only through the class' methods, you might get an idea that you can never make any variable public, and that each variable should be accessed through const getters for read access, and non-const setters for write access. This is a high form of purism and abuse of getters and setters will lead to a lot of boilerplate code and will slow down development. Thanks to const correctness in C++, you can sometimes safely define classes with public variables, and share them with other classes as const references. The other classes will never be able to modify the public variables, therefore no individual getters and setters are needed, for example:
#include <iostream> #include <vector> class MovementParameters { public: MovementParameters() : x(0), y(0), vx(0.5), vy(0.2) // initialization list {} double x, y; // public members for easy access without 4 pairs of getters and setters double vx, vy; void Update( double dt ) // This can be called only by owners of non-const reference { x += vx * dt; y += vy * dt; } private: }; class GeneralShipData { public: // these parameters can't be modified by outside classes const MovementParameters & GetMovParams() const { return m_movParams; } void Update() // This can be called only by owners of non-const reference to object of this class { double dt = 1; m_movParams.Update( dt ); } private: MovementParameters m_movParams; // the member of interest // ... // other objects // ... }; void PhysicsTeam( GeneralShipData & gsd ) { gsd.Update(); // Non-const reference lets me call the non-const Update method } void GraphicsTeam( const GeneralShipData & gsd ) // Enforcing a conversion to a const reference { // gsd.Update(); // sabotage by graphical team unsuccessful - compilation error const MovementParameters & movPar = gsd.GetMovParams(); // This is all I can do // movPar.x = 3; // Missed again - compilation error // but I can still display graphics! std::cout << "x = " << movPar.x << ", y = " << movPar.y << std::endl; } int main() { GeneralShipData gsd; for (int i = 0; i < 10; ++i) { PhysicsTeam( gsd ); GraphicsTeam( gsd ); } return 0; }
Further reading[edit]
- How to design programs, MIT Press : While based on Scheme as programming language and pretty old, it describes many architecture aspects.
- How to write unmaintainable code : Educative satire on the question, how to make your development harder than necessary.