| Lesson 2 | Integrating objects: C++ classes |
| Objective | Explain how C++ classes integrate at the source code. |
A software component is an independent module that provides services through interfaces. Given this definition, what makes a software component different from a C++ class or any other object-based unit of method and data encapsulation? A C++ class provides services through member functions whose implementation is compiled directly into client code at build time. A software component provides services through interfaces that are defined independently of the implementation, allowing the implementation to change without affecting compiled client code.
The distinction matters in practice because it determines whether client code must be rebuilt when the component changes. To see exactly why, consider an integration scenario involving developers at two companies:
ABC has purchased a class library developed at CLI. The class library is delivered as
a set of header files that define classes, an import library to link against, and a DLL
called CLI.dll containing version v1.31 of the CLI library. Elvis is a
developer at ABC making use of class CLILookup. He includes the appropriate
header files, links the required libraries, and writes the following code:
void FindRec(...) {
CLILookup cli;
cli.Init();
// ...
}
After Elvis gets everything working, CLI ships a new version of its C++ class library,
v1.32. The only change is the addition of a couple of private member variables and private
helper functions inside CLILookup. No changes have been made to any public
members or public methods. Even so, Elvis must rebuild his entire application. Why?
C++ class integration is determined at compile time by the header file. The compiler
reads the header and calculates the memory layout of CLILookup: the total
size of the object and the offset of every member variable from the beginning of the
object. When Elvis writes CLILookup cli;, the compiler reserves exactly
sizeof(CLILookup) bytes on the stack, based on the size calculated from the
v1.31 header.
When CLI ships v1.32 with additional private members, the object inside
CLI.dll is larger than the stack frame Elvis's compiler reserved. When
cli.Init() executes, the constructor inside the DLL writes to the full
v1.32-sized memory footprint, but Elvis's stack frame only holds the v1.31-sized
allocation. The constructor overwrites stack memory that belongs to other local variables
or the return address, producing crashes or unpredictable behavior.
Heap allocation does not solve the problem. If Elvis writes CLILookup* cli = new
CLILookup;, his compiled code calls operator new with the v1.31 size.
The v1.32 constructor then writes into a block that is too small. The result is a heap
corruption instead of a stack corruption, but the underlying cause is the same: the client
compiled against the wrong size.
The root cause is that C++ class integration happens at the source code level. The header file is not just a declaration: it is a description of the internal binary layout of the class. Any change to that layout, even a purely private change invisible to the client's public API, forces a client rebuild. This is the C++ fragile base class problem at the binary distribution level.
COM solves the fragile base class problem by specifying contracts at the binary level
rather than the source code level. A COM component exposes functionality exclusively
through interfaces. An interface is a pure abstract base class containing only a vtable
pointer and virtual function entries. The client holds a pointer to the interface, never
to the implementing class. The implementing class adds its own private member variables
after the vtable in memory, entirely invisible to the client. The client never calculates
sizeof the implementing class, never allocates it directly, and therefore
never depends on its internal layout.
The vtable layout of a published interface is fixed at design time by the interface definition and by the IID (Interface Identifier) that identifies it. Once an interface is published, its vtable layout never changes. The implementing DLL can add private member variables, add private helper functions, or rewrite its entire internal logic without altering the vtable that the client holds a pointer to. The client's compiled code continues to call through the same vtable entries at the same offsets.
Instead of shipping header files that expose the full CLILookup class
definition, CLI ships a COM component that implements an interface named
ICLILookup. Elvis receives the interface definition as a MIDL-generated
header and a type library. His code at ABC becomes:
#include <initguid.h>
#include "CLILookup_i.c" // MIDL-generated: contains CLSID_CLILookup and IID_ICLILookup
void FindRec(...) {
ICLILookup* pLookup = nullptr;
HRESULT hr = CoCreateInstance(
CLSID_CLILookup,
nullptr,
CLSCTX_INPROC_SERVER,
IID_ICLILookup,
reinterpret_cast<void**>(&pLookup)
);
if (SUCCEEDED(hr)) {
pLookup->Init();
// ... use the interface
pLookup->Release(); // decrement reference count when done
}
}
Note: the raw ICLILookup pointer and explicit Release() call
are shown here to make COM reference counting visible. In production code, use
CComPtr<ICLILookup> as covered in lesson 5 of module 1. CComPtr
calls Release() automatically when it goes out of scope, eliminating the
manual call and preventing leaks on error paths.
When CLI ships v1.32 with additional private members, the vtable layout of
ICLILookup is unchanged. Elvis's compiled FindRec function
continues to call CoCreateInstance with the same CLSID, receives an
ICLILookup pointer with the same vtable layout, and calls Init()
at the same vtable offset. No rebuild is required. Elvis drops the new
CLI.dll into place and the application continues to work correctly.
ICLILookup
is fixed by the interface definition. Private implementation changes in the DLL do not
alter the vtable and do not force client rebuilds. The client's compiled code works with
any version of the DLL that implements the same interface.ICLILookup2, obtained via QueryInterface. Existing clients that know only
ICLILookup continue to work without modification.IUnknown::AddRef
and Release provide reference-counted lifetime management that works correctly
across DLL and process boundaries, where C++ delete would call the wrong
allocator's destructor.The CLI/ABC scenario makes the core distinction concrete. C++ class library integration is fragile because the client depends on the internal binary layout of the class at compile time. Any private change forces a full rebuild across every client that uses the class. COM binary integration is robust because the client depends only on the vtable layout of the interface, which is fixed and immutable once published. Private changes inside the implementing DLL are invisible to compiled clients.
COM was Microsoft's answer to the fragility of shipping and evolving C++ class libraries in binary form across company and language boundaries. It replaces the "include-the-header-and-link" coupling model with a "query-for-interface-and-use" model in which the contract between client and component is specified at the binary vtable level, independent of any particular language, compiler, or class layout.
The next lesson presents the same CLI/ABC scenario using a COM component end to end,
covering how IUnknown, QueryInterface, IDL, and the MIDL compiler work
together to enable the binary integration described here.