An Object Lesson in Binary Compatibility
A riddle for you, friends: When is changing a method from return void
to return Something
a breaking change?
If you already know the answer, then why hadn’t you told me? Could’ve saved me a fair bit of embarrassment. Ah well, maybe I missed your call.
First, a story
I had the opportunity to contribute to the HtmlTags open-source project
That’s when I found the AddStyle and AddJavaScript methods. Their friends returned an HtmlTag, but they returned void. They would be easier to test if they returned an HtmlTag, but that’s not a good justification for changing a method. But they would be more consistent if they behaved like their neighbors, and that is a sufficient justification to consider it.
HtmlTags is a library meant to be consumed by other applications. It was already in use in the wild. Therefore, it was important not to change its API. It had to work like it always had, else we’d cause considerable grief to the developers using our code. I’ve been the consumer of a service that made breaking changes to its API, and I have choice things to say about the team that saddled me with that noise.
I thought really carefully. I was contemplating changing the return type of a public method in an in-use API. But all those uses would be calling it as if it returned nothing, and you can invoke a method without using its return value. This would be just like that. I asked a coder-buddy for advice. We convinced each other: What harm could it do?
What about the riddle?
Here we come to the answer to the riddle. If you’re changing a library that will be called by other applications, then there are many seemingly harmless changes that are actually breaking changes, including changing a method’s return type. When the consuming assembly is compiled, it is built with instructions to find a method named Whatever
that returns void, but the changed assembly’s manifest contains only a method named Whatever
that returns string. No match.
Computers. They’re so literal.
To fix it, you merely need to recompile the consuming application, but you don’t discover the problem until assemblies are loaded at run-time, an annoying time to discover exceptions. I coded up a simple example that illustrates the problem. Feel free to follow along at home.
See the problem in action
Make two separate solutions, a class library called MessageOutputter and a console application called ConsoleMessager that references MessageOutputter. First, build MessageOutputter with a return void
method.
namespace MessageOutputter { public class Outputter { private string _message = "This is the outputter, version 1."; public string Emit() { return _message; } public void UpdateVersion(string versionNumber) { _message = string.Format("I've been altered by the UpdateVersion method. I am version {0}.", versionNumber); } } }
UpdateVersion modifies a private field but does not return a value. Compile that solution and put its dll into a lib folder from which you will reference it in ConsoleMessager. Open the ConsoleMessager solution and add a reference to the MessageOutputter.dll. Write a method that uses the MessageOutputter.
namespace ConsoleMessager { class Program { static void Main(string[] args) { var outputter = new MessageOutputter.Outputter(); Console.WriteLine("Calling the outputter:"); Console.WriteLine(outputter.Emit()); Console.WriteLine("Asking the outputter to update, then calling it again."); outputter.UpdateVersion("Updated 1"); Console.WriteLine(outputter.Emit()); Console.Read(); } } }
The UpdateVersion method is called, not expecting a return value. Build and run the ConsoleMessager.
Return to the MessageOutputter solution and modify the UpdateVersion method to return the message after it modifies it.
namespace MessageOutputter { public class Outputter { private string _message = "This is the outputter, version 2."; public string Emit() { return _message; } public string UpdateVersion(string versionNumber) { _message = string.Format("I've been altered by the UpdateVersion method. I am version {0}.", versionNumber); return _message; } } }
Now UpdateVersion returns a string. Build the solution and copy its new dll back into the lib folder, and into the Debug or Release folder under ConsoleMessager/bin, so that the ConsoleMessager will run with your new version of the dll. Run ConsoleMessager and you will encounter the error:
The MissingMethodException indicates that ConsoleMessager, when looking within the MessageOutputter library, could not find an UpdateVersion method that returns void. Reopen the ConsoleMessager solution. Before you even build, IntelliSense will tell you that UpdateVersion returns a string now. Build ConsoleMessager and run it again, to see that it works successfully.
Wiser now
No one raked me over the coals—in fact, my intro-to-OSS experience was thoroughly positive, and I can’t wait to do more—but I know I caused some time-consuming inconvenience to my fellow devs. I have now learned that Binary Compatibility is not the same as Source Compatibility. Conflating the two is like saying, “Works on my machine.” I hope this write-up can help you avoid a similar goof.