views:

1110

answers:

6

I would like to gather as much information as possible regarding API versioning in .NET/CLR, and specifically how API changes do or do not break client applications. First, let's define some terms:

API change - a change in the publicly visible definition of a type, including any of its public members. This includes changing type and member names, changing base type of a type, adding/removing interfaces from list of implemented interfaces of a type, adding/removing members (including overloads), changing member visibility, renaming method and type parameters, adding default values for method parameters, adding/removing attributes on types and members, and adding/removing generic type parameters on types and members (did I miss anything?). This does not include any changes in member bodies, or any changes to private members (i.e. we do not take into account Reflection).

Binary-level break - an API change that results in client assemblies compiled against older version of the API potentially not loading with the new version. Example: removing an existing class member.

Source-level break - an API change that results in existing code written to compile against older version of the API potentially not compiling with the new version. Already compiled client assemblies work as before, however. Example: adding a new overload that can result in ambiguity in method calls that were unambiguous previous.

Source-level quiet semantics change - an API change that results in existing code written to compile against older version of the API quietly change its semantics, e.g. by calling a different method. The code should however continue to compile with no warnings/errors, and previously compiled assemblies should work as before. Example: implementing a new interface on an existing class that results in a different overload being chosen during overload resolution.

The ultimate goal is to catalogize as many breaking and quiet semantics API changes as possible, and describe exact effect of breakage, and which languages are and are not affected by it. To expand on the latter: while some changes affect all languages universally (e.g. adding a new member to an interface will break implementations of that interface in any language), some require very specific language semantics to enter into play to get a break. This most typically involves method overloading, and, in general, anything having to do with implicit type conversions. There doesn't seem to be any way to define the "least common denominator" here even for CLS-conformant languages (i.e. those conforming at least to rules of "CLS consumer" as defined in CLI spec) - though I'll appreciate if someone corrects me as being wrong here - so this will have to go language by language. Those of most interest are naturally the ones that come with .NET out of the box: C#, VB and F#; but others, such as IronPython, IronRuby, Delphi Prism etc are also relevant. The more of a corner case it is, the more interesting it will be - things like removing members are pretty self-evident, but subtle interactions between e.g. method overloading, optional/default parameters, lambda type inference, and conversion operators can be very surprising at times.

A few examples to kickstart this:

Adding new method overloads

Kind: source-level break

Languages affected: C#, VB, F#

API before change:

public class Foo
{
    public void Bar(IEnumerable x);
}

API after change:

public class Foo
{
    public void Bar(IEnumerable x);
    public void Bar(ICloneable x);
}

Sample client code working before change and broken after it:

new Foo().Bar(new int[0]);

Adding new implicit conversion operator overloads

Kind: source-level break.

Languages affected: C#, VB

Languages not affected: F#

API before change:

public class Foo
{
    public static implicit operator int ();
}

API after change:

public class Foo
{
    public static implicit operator int ();
    public static implicit operator float ();
}

Sample client code working before change and broken after it:

void Bar(int x);
void Bar(float x);
Bar(new Foo());

Notes: F# is not broken, because it does not have any language level support for overloaded operators, neither explicit nor implicit - both have to be called directly as op_Explicit and op_Implicit methods.

Adding new instance methods

Kind: source-level quiet semantics change.

Languages affected: C#, VB

Languages not affected: F#

API before change:

public class Foo
{
}

API after change:

public class Foo
{
    public void Bar();
}

Sample client code that suffers a quiet semantics change:

public static class FooExtensions
{
    public void Bar(this Foo foo);
}

new Foo().Bar();

Notes: F# is not broken, because it does not have language level support for ExtensionMethodAttribute, and requires CLS extension methods to be called as static methods.

+6  A: 

Changing a method signature

Kind: Binary-level Break

Languages affected: C# (VB and F# most likely, but untested)

API before change

public static class Foo
{
    public static void bar(int i);
}

API after change

public static class Foo
{
    public static bool bar(int i);
}

Sample client code working before change

Foo.bar(13);
Justin Drury
In fact, it can be a source-level break too, if someone tries to create a delegate for `bar`.
Pavel Minaev
Thats true too. I found this particular problem when I made some changes to the printing utilities in my companies application. When the update was released, not all the DLLs that referenced thi utilities were recompiled and released so it throw a methodnotfound exception.
Justin Drury
This goes back to the fact that return types do not count for the signature of the method. You cannot overload two functions based solely on the return type either. Same problem.
Jason Short
Oh, you actually can overload a method based solely on return type just fine - in CLR. C# won't be able to distinguish, yes, but adding a new method with a different return type (but same name and arguments) is by itself not a binary breaking change.
Pavel Minaev
By its self it may not be a binary breaking change (Something I didn't know though, thanks :), but in the higher level languages it probably IS a binary breaking change, at least, as far as C# is concerned anyway.
Justin Drury
+4  A: 

This one is a perhaps not-so-obvious special case of "adding/removing interface members", and I figured it deserves its own entry in light of another case which I'm going to post next. So:

Refactoring interface members into a base interface

Kind: breaks at both source and binary levels

Languages affected: C#, VB, C++/CLI, F# (for source break; binary one naturally affects any language)

API before change:

interface IFoo
{
    void Bar();
    void Baz();
}

API after change:

interface IFooBase 
{
    void Bar();
}

interface IFoo : IFooBase
{
    void Baz();
}

Sample client code that is broken by change at source level:

class Foo : IFoo
{
   void IFoo.Bar() { ... }
   void IFoo.Bar() { ... }
}

Sample client code that is broken by change at binary level;

(new Foo()).Bar();

Notes:

For source level break, the problem is that C#, VB and C++/CLI all require exact interface name in the declaration of interface member implementation; thus, if the member gets moved to a base interface, the code will no longer compile.

Binary break is due to the fact that interface methods are fully qualified in generated IL for explicit implementations, and interface name there must also be exact.

Implicit implementation where available (i.e. C# and C++/CLI, but not VB) will work fine on both source and binary level. Method calls do not break either.

Pavel Minaev
+3  A: 

This one was very non-obvious when I discovered it, especially in light of the difference with the same situation for interfaces. It's not a break at all, but it's surprising enough that I decided to include it:

Refactoring class members into a base class

Kind: not a break!

Languages affected: none (i.e. none are broken)

API before change:

class Foo
{
    public virtual void Bar() {}
    public virtual void Baz() {}
}

API after change:

class FooBase
{
    public virtual void Bar() {}
}

class Foo : FooBase
{
    public virtual void Baz() {}
}

Sample code that keeps working throughout the change (even though I expected it to break):

// C++/CLI
ref class Derived : Foo
{
   public virtual void Baz() {{

   // Explicit override    
   public virtual void BarOverride() = Foo::Bar {}
};

Notes:

C++/CLI is the only .NET language that has a construct analogous to explicit interface implementation for virtual base class members - "explicit override". I fully expected that to result in the same kind of breakage as when moving interface members to a base interface (since IL generated for explicit override is the same as for explicit implementation). To my surprise, this is not the case - even though generated IL still specifies that BarOverride overrides Foo::Bar rather than FooBase::Bar, assembly loader is smart enough to substitute one for another correctly without any complaints - apparently, the fact that Foo is a class is what makes the difference. Go figure...

Pavel Minaev
+1  A: 

API change:

  1. Adding the [Obsolete] attribute (you kinda covered this with mentioning attributes; however, this can be a breaking change when using warning-as-error.)

Binary-level break:

  1. Moving a type from one assembly to another
  2. Changing the namespace of a type
  3. Adding a base class type from another assembly.
  4. Adding a new member (event protected) that uses a type from another assembly (Class2) as a template argument constraint.

    protected void Something<T>() where T : Class2 { }
    
  5. Changing a child class (Class3) to derive from a type in another assembly when the class is used as a template argument for this class.

    protected class Class3 : Class2 { }
    protected void Something<T>() where T : Class3 { }
    

Source-level quiet semantics change:

  1. Adding/removing/changing overrides of Equals(), GetHashCode(), or ToString()


(not sure where these fit)

Deployment changes:

  1. Adding/removing dependencies/references
  2. Updating dependencies to newer versions
  3. Changing the 'target platform' between x86, Itanium, x64, or anycpu
  4. Building/testing on a different framework install (i.e. installing 3.5 on a .Net 2.0 box allows API calls that then require .Net 2.0 SP2)

Bootstrap/Configuration changes:

  1. Adding/Removing/Changing custom configuration options (i.e. App.config settings)
  2. With the heavy use of IoC/DI in todays applications, it's somethings necessary to reconfigure and/or change bootstrapping code for DI dependent code.

Update:

Sorry, I didn't realize that the only reason this was breaking for me was that I used them in template constraints.

csharptest.net
"Adding a new member (event protected) that uses a type from another assembly." - IIRC, the client only needs to reference the dependent assemblies that contain base types of the assemblies that it already references; it doesn't have to reference assemblies that are merely used (even if types are in method signatures); I'm not 100% sure about this. Do you have a reference for precise rules for this? Also, moving a type can be non-breaking if `TypeForwardedToAttribute` is used.
Pavel Minaev
That "TypeForwardedTo" is news to me, I'll check it out. As for the other, I'm not also 100% on it... let me see if can repro and I'll update the post.
csharptest.net
+1  A: 

Convert an explicit interface implementation into an implicit one.

Kind of Break: Source and Binary

Languages Affected: All

This is really just a variation of changing a method's accessibility - its just a little more subtle since it's easy to overlook the fact that not all access to an interface's methods are necessarily through a reference to the type of the interface.

API Before Change:

public class Foo : IEnumerable
{
    public IEnumerator GetEnumerator();
}

API After Change:

public class Foo : IEnumerable
{
    IEnumerator IEnumerable.GetEnumerator();
}

Sample Client code that works before change and is broken afterwards:

new Foo().GetEnumerator(); // fails because GetEnumerator() is no longer public
LBushkin
+1  A: 

Convert an implicit interface implementation into an explicit one.

Kind of Break: Source

Languages Affected: All

The refactoring of an implicit interface implementation into an explicit one is more subtle in how it can break an API. On the surface, it would seem that this should be relatively safe, however, when combined with inheritance in can cause problems.

API Before Change:

public class Foo : IEnumerable
{
    IEnumerator IEnumerable.GetEnumerator() { yield return "Foo"; }
}

API After Change:

public class Foo : IEnumerable
{
    public IEnumerator GetEnumerator() { yield return "Foo"; }
}

Sample Client code that works before change and is broken afterwards:

class Bar : Foo, IEnumerable
{
    IEnumerator IEnumerable.GetEnumerator() // silently hides base instance
    { yield return "Bar"; }
}

foreach( var x in new Bar() )
    Console.WriteLine(x);    // originally output "Bar", now outputs "Foo"
LBushkin
Sorry, I don't quite follow - surely the sample code before API change wouldn't compile at all, since before the change `Foo` didn't have a public method named `GetEnumerator`, and you're calling the method via a reference of type `Foo`...
Pavel Minaev
Indeed, I tried to simplify an example from memory and it ended up 'foobar' (pardon the pun). I updated the example to correctly demonstrate the case (and be compilable).
LBushkin
In my example, the problem is caused by more than just the transition of a interface method from being implicit to being public. It depends on the way the C# compiler determines which method to call in a foreach loop. Given the resolution rules the compiler ses, it switches from the version in the derived class to the version in the base class.
LBushkin
You forgot `yield return "Bar"` :) but yeah, I see where this is going now - `foreach` always calls the public method named `GetEnumerator`, even if it's not the real implementation for `IEnumerable.GetEnumerator`. This seems to have one more angle: even if you have just one class, and it implements `IEnumerable` explicitly, this means that it's a source breaking change to add a public method named `GetEnumerator` to it, because now `foreach` will use that method over interface implementation. Also, the same problem is applicable to `IEnumerator` implementation...
Pavel Minaev