Containment Delegation  «Prev  Next»

Lesson 2 Integrating objects: C++ classes
Objective Explain how C++ classes integrate at the source code.

Integrating Objects: C++ Classes

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:

  1. ClassLibraries Inc. (CLI)
  2. AppBuilders Company (ABC)

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();
    // ...
}

CLI Ships a New Version of its C++ Class Library

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.



What COM Does Differently

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.

The CLI / ABC Scenario with COM

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.

Key Advantages of COM Binary Integration

  1. Binary compatibility: the vtable layout of 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.
  2. Interface immutability: once a COM interface is published, its GUID and method signatures are frozen. New functionality goes into a new interface, for example ICLILookup2, obtained via QueryInterface. Existing clients that know only ICLILookup continue to work without modification.
  3. Language independence: clients need only the interface definition, provided as a MIDL-generated header or a type library. They do not need the C++ class layout. A client written in C#, Visual Basic, Delphi, or Python can consume the same COM interface that a C++ client uses.
  4. Dynamic loading and versioning: the COM registration and activation system uses the CLSID to locate the correct server DLL or EXE at runtime. The system can be configured to load a specific version or the latest registered version without requiring client code changes.
  5. Lifetime management across DLL boundaries: 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.


C++ Source Integration versus COM Binary Integration

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.


SEMrush Software