As the speed of change in both business and technology accelerates, it's increasingly clear that
software development needs to move from a craft to a modern industrial process capable of using
componentization to reduce cost and time-to-market. In response, Software Development is launching
a new column, "Beyond Objects," to address the architecture, process, and technology issues of
component-based development (CBD), and discuss related technologies that developers are using
today, including: COM and ActiveX, client-side and Enterprise Java Beans, CORBA, and software
patterns. With contributions from a rotating group of prominent software methodologists, the
mission of the "Beyond Objects" column is to provide careful, thoughtful, and timely insights
to help developers and team leaders realize the benefits of component-based tools and techniques.
In this month's column, Clemens Szyperski, author of the Jolt Award-winning book Component
SoftwareBeyond Object-Oriented Programming (Addison-Wesley, 1998), discusses approaches
to versioning component-based software and avoiding DLL Hell.
Roger Smith
A new software package is installed and something loaded previously breaks. However, both packages
work just fine in isolation. That is not good, but it is state-of-the-art. On all platforms, there
are essentially two possibilities: deploy statically linked and largely redundant packages that don't
share anything but the operating systemor you'll have DLL Hell on your doorstep. The former
strategy doesn't work well, either. Operating systems change, and it's unclear what is part of the
shared platformdevice drivers, windowing systems, database systems, or network protocols.
Let me step back for a moment. Constructing software by assembling components holds great promise for
next-generation software engineering. I could even go as far as to claim that it isn't engineering
before you master this stepit's mere crafting, much like state-of-the-art manufacturing prior
to the Industrial Revolution. So, clearly, we should begin building component-based software.
There are downsides, however. Moving from the crafting of individual pieces to lines of products based
on shared components undermines our present understanding and practice of how to build software.
Step by step, you must question your current approaches, either replacing or "component-proofing"
them as you go. Here, I will look at the interaction of versioning and components, a conundrum so
deep that even our intense spotlight might fail to illuminate it. Surprisingly, there are practical
solutions to much of this problem.
Relative Decay
Why do you version software? Because you got it wrong last time around? That is one common reason,
but there is a bigger and better one: Things might have changed since you originally captured
requirements, analyzed the situation, and designed a solution. You can call this relative decay.
Software clearly cannot decay as suchjust as a mathematical formula cannot. However,
softwarelike a formularests on contextual assumptions of what problems you need
to solve and what best machinery to do so. As the world around a frozen artifact changes,
the artifact's adequateness changesit could be seen as decaying relative to the changing
world.
Reuse Across Versions
Once we accept that we must evolve our software artifacts to keep them useful over time, we must
decide how to achieve this. A straightforward answer is: throw the old stuff away and start from
scratch. Strategically, this can be the right thing to do. Tactically, as your only alternative,
this is far too expensive. To avoid the massive cost of complete restarts for every version or
release, you can aim for reuse across versions. In a world of monolithic applications, you traditionally
assumed that you would retain the source code base, meddle with it, and generate the next version.
The strategic decision to start from scratch is thus reduced to throwing out parts of the source
code base.
With the introduction of reuse of software units across deployed applications, the picture changes.
Such units are now commonplace: shared object files on UNIX, dynamic link libraries on Windows, and
so on. (I hesitate to call these components for reasons that will become apparent shortly.)
Applying the old approach of meddling with, and rebuilding, such shared units tends to break them.
The reason is simple: if multiple deployed applications exist on a system that share such a unit,
then you cannot expect all of them to be upgraded when only one needs to be. This is particularly
true when the applications are sourced from separate providers.
Why does software break when you upgrade a shared unit that it builds on? There are a number of
reasons and all of them are uniformly present across platforms and approaches. Here is a list of
important cases:
Unit A depends on implementation detail of unit B, but that is not known to B's developer. Even A's
developer is often unaware of this hidden dependency. A lengthy debugging session when developing A
might have led to hard-coding observed behavior of B into A. With the arrival of a new version of B,
A might break.
Interface specifications are not precise enough. What one developer might perceive as a guarantee
under a given specification (often just a plain English description), another might perceive as not
covered. Such discrepancies are guaranteed to break things over time.
Unit A and unit B are coupled in a way that isn't apparent from the outside. For example, both depend
on some platform API that is stateful, or both share some global variables. The resulting fragility
prevents units A and B from safely being used in another context and often even in isolation.
Unit A and unit B are both upgraded to a new version. However, some of the connections between A
and B go through a third unit that has not been upgraded. This "middleman" has a tendency to present
A to B as if A were still of the old version, leading to degraded functionality at best and
inconsistencies at worst.
If your shared units declared which other units they depended on and strictly avoided undocumented
coupling, then you could call them components. Actually, they should also be well documented and
satisfy a few other criteria, such as specification of requiredbesides providedinterfaces
and separate deployability (adherence to some "binary" standard).
A Middleman Scenario
Of all the problems introduced earlier, the last requires special attention. It is difficult
enough to consider what happens at the boundary between two components if one is upgraded while
the other is not. However, introducing a middleman scenario truly separate the half-working
(really, the half-broken) from true solutions.
Consider the following case, as shown in Figure 1. Our component ScreenMagic supports some interface
IRenderHTML. An instance (object) created by ScreenMagic implements this interface (among others).
Our component WebPuller uses this instance to render HTML source that it just pulled off the World Wide
Web. However, WebPuller's instances don't acquire references to ScreenMagic's instances directly.
Instead, the ScreenMagic instances are held on to by some component Base, which WebPuller contacts
to get these instances. In fact, WebPuller doesn't know about ScreenMagic, just IRenderHTML,
and contacts Base to get the rendering object to be used in some context. At the same time,
Base doesn't use IRenderHTMLit just holds onto objects that implement this interface.
Figure 1: The Middleman Scenario |
Now assume that a new version of HTML comes along and only WebPuller gets upgraded initially. If you
just modify the IRenderHTML's meaning, then Base won't be affected. However, in an installation that
holds the new WebPuller and the old ScreenMagic, you might get unexpected error messages from
ScreenMagic. (This is not a perfect example since HTML was so ill-defined from the beginning that
no existing software can guess at what is meant and do something regardless. It is thus unlikely
that you will get an error message from ScreenMagic, but it still might crash.)
This incompatibility problem is caused by changing the contract of an interface without changing all
the affected implementations (which is generally infeasible). To avoid such a problem, you could
instead define a new interface, IRenderHTML2, that specifies that the used HTML streams will
be of a newer version. If you managed to get WebPuller instances to implement both IRenderHTML
and IRenderHTML2, you can eliminate the incompatibility. Since Base and ScreenMagic aren't aware
of IRenderHTML2, they will continue to work via IRenderHTML.
Next, you can upgrade ScreenMagic to also support IRenderHTML2, but still leave Base as it is.
Since Base doesn't use IRenderHTML, there shouldn't be a problem. However, since Base cannot
statically guarantee that returned objects would implement IRenderHTML2, WebPuller will now
need a way to query objects for whether they implement IRenderHTML2 or merely IRenderHTML.
A standard approach is to use classes that implement multiple interfaces. For example, you
would write:
class ScreenMagicRenderer implements IRenderHTML, IRenderHTML2 ...
Then, WebPuller would use dynamic type inspection to find out whether the rendering object
obtained from Base implements IRenderHTML2:
if (aRenderer instanceof IRenderHTML2) { ...new version... }
else { ...old version...}
Now, all combinations of old and new can coexist and cooperate. This coexistence is very important
without it, there is no plausible story of components as units of deployment. DLL Hell is caused by
the inability of newer DLLs to coexist with older ones. Of course, it isn-t necessary to continue
supporting old versions (old interfaces) for all time. After declaring deprecation, you can eventually
drop interfaces. The point is that you must maintain a window of permissible versions and ensure
those versions coexist.
The Devil in the Detail
It wouldn't be DLL Hell if the devil weren't in the detail; the approach I've outlined doesn't really
work in C++, Java, and many other object-oriented languages. In these languages, a class merges all
methods from all implemented interfaces (base classes). To avoid clashes, you must ensure that all
methods in IRenderHTML that need to change semantics are renamed in IRenderHTML2. Then a class like
ScreenMagicRenderer can still implement both. Renaming methods in addition to renaming the interface
itself is inconvenient, but possible.
A second nasty detail is interface inheritance. When interfaces are derived from other interfaces
defined elsewhere, the concept of renaming all method names when renaming the interface becomes
infeasible. If faced with this situation, the cleanest way out is to not rename methods and to
use adapter objects instead of classes that implement multiple interfaces. In other words:
class ScreenMagicRenderer implements IRenderHTML ...
class ScreenMagicRenderer2 implements IRenderHTML2 ...
This way, you have avoided all problems of method merging. However, the component Base is in
trouble again'if it only knows about IRenderHTML, it will only hold on to instances of
ScreenMagicRenderer, not ScreenMagicRenderer2. To resolve this problem, you could introduce a
standard way of navigating from one to the other. Let's call this INavigate:
interface INavigate {
Object GetInterface (String iName); //*GetInterface returns null if no such interface */
}
Now, WebPuller would get an object that implements IRenderHTML from Base, but would ask that object
for its IRenderHTML2 interface. If the provider were the new ScreenMagic, then that interface would
be supported and WebPuller would use it.
Supporting combinations of versioned components in this manner is plausible. After smoothing some
rough edges and institutionalizing this approach as a convention, it would enrich most existing
approaches. You may have picked up by now that this is exactly how COM does it (where INavigate
is called IUnknown, GetInterface is called QueryInterface, and GUIDs are used instead of strings
for unique interface naming).
Curbing the Problem
If COM solves this problem, why does DLL Hell still happen on Windows platforms? Because a large
number of DLLs are not pure COM servers but instead rely on traditional procedural entry points.
High degrees of implicit coupling are another problem. Clearly, DLL Hell is a problem that is,
in one form or another, present on practically all of today's platformsalthough, as I've sketched
in this column, there are approaches that help curbif not solvethis problem today.