Yesterday I had a plan to use reflection to solve the compatibility versioning problem with my software components.
Basically I need to change an interface that existing customers have implemented. And I want to maintain compatibility so that no-one has to recompile anything.
I did try to anticipate this problem by providing for changes in an abstract support class. Users were advised to extend this class rather than implement the interface directly so that future changes could be hidden from them.
Well it looks like I might be able to get away with it after all. And without any reflection. Here is a concrete example of the problem, and a proposed solution.
The solution satisfies the requirement that existing code must not be changed. Existing binaries must still work, and existing code must still compile without errors. But the interface will change. Here goes…
First, here's the current situation, demonstrated in code. I've used a simple example to show the essence of the problem.
We have ColorManager
class, to which Colors
can be added. Colors
have names and numbers. To implement a new Color
you extend the ColorSupport
abstract support class which in turn implements the Color
interface. Instead of implementing the interface methods directly, you implement protected *Impl
methods instead. These are called by ColorSupport
. This insulates you from changes to Color
.
The change is that the Color.getName
method is to be renamed Color.getCode
, and the ColorSupport.getNumberImpl
method is to be made protected
(it was accidentally released as public
).
Anyway, here's the initial code:
public class ColorManager { public void addColor( Color pColor ) { System.out.println( "color:"+pColor.getName() +","+pColor.getNumber() ); } public static final void main( String[] args ) { ColorManager cm = new ColorManager(); Red red = new Red(); cm.addColor(red); } } public interface Color { public int getNumber(); // this will be changed to getCode public String getName(); } // this is the abstract support class public abstract class ColorSupport implements Color { public int getNumber() { return getNumberImpl(); } public String getName() { return getNameImpl(); } // this needs to be made protected public abstract int getNumberImpl(); protected abstract String getNameImpl(); } public class Red extends ColorSupport { public int getNumberImpl() { return 0; } protected String getNameImpl() { return "red"; } }
These classes mirror the current design of the LineListener and LineProvider callback interfaces in CSV Manager.
So the class Red
represents a user created class. It cannot change. And neither can any existing Red.class
bytecode.
The solution has to take into account the following: The old ColorSupport
will be deprecated, but still supported. The next major version will use a changed ColorSupport
and remove all compatibility code. This is allowed as compatibility can be changed on a major release. ColorSupport
is a name we want to keep (for consistency across product lines). So if we use a new support class in the meantime, we have to insulate new users who implement the new, correct, Color
methods. We must make sure that their code required no changes when we move to the next major version!
So here's the basic idea: apply the changes to Color
, which breaks the old ColorSupport
and Red
Classes. Detach ColorSupport
from Color
and make it a standalone class. Add a method to ColorManager
that can accept ColorSupport
. This ensures that old Color
implementations that extend ColorSupport
still work with ColorManager
.
Next, create a ColorSupportImpl
class. This is the new ColorSupport.
It will replace the old ColorSupport
with the next major version. ColorSupportImpl
extends the new Color
interface directly. It works just the same as the old design. But we know that the name ColorSupportImpl
is temporary and will be dropped. So we need to place an insulation class in between the concrete color classes and ColorSupportImpl
. To do this we change the recommended way to implement colors. For every color, there is a specific color support class. For example, Green
will extend GreenSupport
which then extends ColorSupportImpl
.
That still leaves one little problem. What about colors that we have not defined? What about user-defined colors? We need to specify that custom colors extend an insulation class rather than ColorSupport,
as is currently the case. We'll use CustomColor
. So this suggests a change to the standard policy across product lines. Custom concrete user classes extend an abstract custom class which extends an abstract support class that implements the interface in question.
Wow, that seems like a really complicated way to do something simple. In a normal environment you would never do this. You would refactor and modify client code. And for released software components you can't do this. Releasing commercial software creates an entirely different set of issues. In this case it is far far more important to support existing customers than it is to refactor to a clean design. The vendor has to accept the responsibility for maintaining compatibility for reasonable periods and between clear boundaries. You only need to take a look at the situation with plugins to Eclipse or Firefox to see how difficult this problem is. And they get it mostly right!
Here's the code for the new version. Watch out, we've got lots more classes!
public class ColorManager { public void addColor( Color pColor ) { System.out.println( "color:"+pColor.getCode() +","+pColor.getNumber() ); } // this keeps the old colors working public void addColor( ColorSupport pColorSupport ) { addColor( new ColorSupportFixer(pColorSupport) ); } public static final void main( String[] args ) { ColorManager cm = new ColorManager(); // this is an old color // old custom colors will work this way as well Red red = new Red(); cm.addColor(red); // this is a new standard color Green green = new Green(); cm.addColor(green); // this is a new custom color Blue blue = new Blue(); cm.addColor(blue); } } public interface Color { public int getNumber(); // this is the new version public String getCode(); } // this is the same as before, but no longer // implements Color public abstract class ColorSupport { public int getNumber() { return getNumberImpl(); } public String getName() { return getNameImpl(); } public abstract int getNumberImpl(); protected abstract String getNameImpl(); } // this is unchanged - just what we want! public class Red extends ColorSupport { public int getNumberImpl() { return 0; } protected String getNameImpl() { return "red"; } } // this is the new verion of ColorSupport public abstract class ColorSupportImpl implements Color { public int getNumber() { return getNumberImpl(); } public String getCode() { return getCodeImpl(); } protected abstract int getNumberImpl(); protected abstract String getCodeImpl(); } // an insulation class, currently does nothing public abstract class GreenSupport extends ColorSupportImpl {} // a new standard color public class Green extends GreenSupport { protected int getNumberImpl() { return 1; } protected String getCodeImpl() { return "green"; } } // an insulation class for custom colors public abstract class CustomColor extends ColorSupportImpl {} // a custom color public class Blue extends CustomColor { protected int getNumberImpl() { return 2; } protected String getCodeImpl() { return "blue"; } } // this hooks up the old and new interfaces public class ColorSupportFixer extends ColorSupportImpl { private ColorSupport iColorSupport; public ColorSupportFixer( ColorSupport pColorSupport ) { iColorSupport = pColorSupport; } protected int getNumberImpl() { return iColorSupport.getNumber(); } // convert getCode to getName protected String getCodeImpl() { return iColorSupport.getName(); } }
Like I said, it's not pretty. But it does allow the API to move forward with full backwards compatibility.
The insulation classes (GreenSupport
and CustomColor
) are empty in the example above and will probably also be empty in the next CSV Manager release (1.2). Their purpose is to allow ColorSupportImpl
to change its name in release 2.0.
And they serve another very important purpose. If in the future further changes arise that require more compatibility workarounds, they allow for the use of a reflection-based solution in ColorSupport
and/or the insulation classes. Thus one layer of changes can be applied on the interface side, and one on the implementation side. This “feels” like the right solution.
Of course, some types of changes (for example, changing method access from public
to protected
) may not be amenable to a reflection-based solution. They may require a third layer of insulation. We'll cross that bridge when we come to it, if we cross it at all. I rely on the belief that as the API converges on an acceptable design, these types of changes will become less of a problem. Once the API has been in use for a longer period, changes become so exponentially expensive that it is better to put up with design mistakes. This is what happened with the standard Java API.
I reckon I am still able to pull this off at this stage in the life-cycle of CSV Manager. The cost will be more complex documentation until 2.0, when the compatibility code can be ditched. And the cost will be increased code complexity inside CSV Manager, which means more work for me to bug fix it all. Of course, I have a large set of unit tests so this should not be a big problem.
Well, it looks like we're set. Any final thoughts before I dive into the code?