Best Practices for Class Member Function Design
A common anti-pattern in class interface design can be seen in the following tree node implementation:
class TreeNode {
public:
void updateParentPtr(TreeNode* newParent) { parent = newParent; }
void changeParent(TreeNode* newParent) { // Problematic interface
updateParentPtr(newParent);
newParent->deleteChild(this);
}
void handleRemoval();
void deleteChild(TreeNode* child) {
children.erase(child);
child->handleRemoval();
}
private:
TreeNode* parent = nullptr;
std::vector<TreeNode*> children;
};
When changeParent is called on a node, its execution flow is:
- Update the node's own parent pointer to point to the new parent
- Invoke the new parent's
deleteChildmethod, passingthis(the current node) as an argument 2.1 The new parent removes the current node from its child list 2.2 The new parent triggershandleRemovalon the current node
This cross-object invocation pattern is unnecessarily convoluted. It shares structural similarity with the visitor pattern's double dispatch, but with a key difference: visitor pattern operates on objects of different abstract types, while here both the current node and parent are instances of the same TreeNode class.
This awkward cross invocation can be completely avoided by implementing the functionality as a static member (or non-member) instead, resulting in much clearer, more intuitive flow:
class TreeNode {
public:
void updateParentPtr(TreeNode* newParent) { parent = newParent; }
void handleRemoval();
void deleteChild(TreeNode* child) {
children.erase(child);
child->handleRemoval();
}
void addChild(TreeNode* child) {
children.push_back(child);
}
static void reparentNode(TreeNode* childNode, TreeNode* newParent) {
if (childNode->parent != nullptr) {
childNode->parent->deleteChild(childNode);
}
newParent->addChild(childNode);
childNode->updateParentPtr(newParent);
}
private:
TreeNode* parent = nullptr;
std::vector<TreeNode*> children;
};
Common Patterns of Module Interaction
This kind of convoluted cross-invocation leads to a broader question: what are the comon patterns for inter-module interaction within the same process? From the caller's perspective, they fall in to three categories:
- Synchronous request for data: The caller initiates the call directly and receives output in return.
Result fetchModuleData(InputParams input, int opts, OutputData& out, Error& err);
- Direct state modification: The caller initiates the call directly to change the module's internal state or behavior.
Status setModuleRuntimeState(RuntimeState newState);
Status initializeModule();
- Passive callback-based interaction: The caller registers a callback, observer, or visitor, and the module invokes the registered handler when it needs to interact back with the caller.
void registerObserver(Observer* obs);
void registerCallback(EventCallback cb);
void acceptVisitor(Visitor* visitor);
Passive interaction patterns require extra caution: they create reverse dependencies between modules, making code harder to follow and debug. Similar to how a double negative makes sentences harder to parse, reverse invocation adds unnecessary cognitive overhead. Whenever a functionality can be implemented with a direct, proactive call, that approach should be preferred.
Prefer Non-Member Non-Friend Functions to Member Functions
This aligns perfectly with Item 23 from Effective C++, which recommends replacing member functions with non-member, non-friend implementations when possible. This guideline leads to the core principle: a class should have as few member functions as possible.
To illustrate this principle, consider the common example of a web browser class with a requirement to clear all user data:
// Approach 1: Add a new member function to the class
class WebBrowser {
public:
void clearCache();
void clearBrowsingHistory();
void deleteAllCookies();
void clearAllUserData();
};
// Approach 2: Implement as a non-member function
class WebBrowser {
public:
void clearCache();
void clearBrowsingHistory();
void deleteAllCookies();
};
void clearAllBrowserData(WebBrowser& browser) {
browser.clearCache();
browser.clearBrowsingHistory();
browser.deleteAllCookies();
}
Encapsulation Benefits
The core goal of encapsulation is to hide internal implementation details from outside code. The fewer parts of code that can access a class's internal private data, the more flexibility we have to modify the class's internal implementation later, since fewer code regions depend on the internal structure.
Only member functions and friend functions can access a class's private data. Adding a non-member function that uses only the existing public interface does not increase the number of functions that can access private data, so it does not reduce encapsulation. Adding a new member function, by contrast, increases the number of functions that can access private state, reducing the overall encapsulation of the class.
Compilation and Flexibility Benefits
In C++, non-member convenience functions can be grouped in the same namespace as the class, split across multiple header files based on functionality:
namespace browser_utils {
class WebBrowser { /* ... */ };
// Convenience functions for bookmark management in a separate header
void saveBookmark(WebBrowser& browser, const std::string& url);
// Convenience functions for privacy operations in another separate header
void clearAllBrowserData(WebBrowser& browser);
}
These non-member functions are just convenience wrappers: users can always achieve the same result by calling the underlying individual public methods even if the convenience function is not available. Grouping related convenience functions in separate files, enabled by C++'s namespace feature that spans multiple translation units, reduces unnecessary compilation dependencies, resulting in faster build times for large projects.