Skip to content

Instantly share code, notes, and snippets.

@klmr
Last active December 7, 2020 16:10
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save klmr/30b8950658bd3991d2b722281f39799c to your computer and use it in GitHub Desktop.
Save klmr/30b8950658bd3991d2b722281f39799c to your computer and use it in GitHub Desktop.
Using generic interfaces in class hierarchies without downcasts (discussed on https://softwareengineering.stackexchange.com/a/366666/2366)
using System;
using Color = System.Drawing.Color;
namespace CsTest {
// Generic interfaces. // (1)
interface IEquatable<in T> {
bool Equals(T other);
}
interface ICloneable<out T> {
T Clone();
}
abstract class Pet : ICloneable<Pet>, IEquatable<Pet> {
private readonly string name;
protected Pet(string name) => this.name = name;
protected Pet(Pet other) : this(other.name) { }
public abstract Pet Clone();
public virtual bool Equals(Pet other) => name == other.name; // (2)
public override string ToString() => string.Format("{0} {1}", GetType().Name, name);
}
class Cat : Pet, ICloneable<Cat>, IEquatable<Cat> {
private readonly Color furColor;
public Cat(string name, Color furColor) : base(name) => this.furColor = furColor;
public Cat(Cat other) : base(other) => this.furColor = other.furColor;
public override Pet Clone() => new Cat(this);
Cat ICloneable<Cat>.Clone() => new Cat(this);
public bool Equals(Cat other) => base.Equals(other) && furColor == other.furColor;
public override string ToString() => String.Format("{0} has {1} fur", base.ToString(), furColor);
}
class Dog : Pet, ICloneable<Dog>, IEquatable<Dog> {
private readonly bool goodBoy = true; // Duh.
public Dog(string name) : base(name) { }
public Dog(Dog other) : base(other) { }
public override Pet Clone() => new Dog(this);
Dog ICloneable<Dog>.Clone() => new Dog(this);
public bool Equals(Dog other) => base.Equals(other) && goodBoy == other.goodBoy;
public override string ToString() => String.Format("{0} {1} a good boy", base.ToString(), goodBoy ? "is" : "isn’t");
}
class Program {
// Helper to perform type-safe upcast without having to assign the result.
static T As<T>(T obj) => obj; // (3)
static void Main(string[] args) {
var cat = new Cat("Cleo", Color.Brown);
var tac = new Cat("Tabby", Color.Brown);
var act = new Cat("Cleo", Color.Black);
var dog = new Dog("Fido");
// Cloning polymorphically:
Pet cc1 = cat.Clone();
// … or via an implicit upcast (*not* downcast):
Cat cc2 = As<ICloneable<Cat>>(cat).Clone(); // (4)
Console.WriteLine("All my clones:\n {0}\n {1}", cc1, cc2);
Console.WriteLine("cat {0} tac", cat.Equals(tac) ? "=" : "≠");
Console.WriteLine("cat {0} act", cat.Equals(act) ? "=" : "≠");
Console.WriteLine("cat {0} cc2", cat.Equals(cc2) ? "=" : "≠");
Console.WriteLine("cat {0} dog", cat.Equals(dog) ? "=" : "≠"); // oops.
}
}
}

Explanation

ICloneable`1 is implemented by dispatching to non-polymorphic copy constructors which, in turn, call the appropriate base class constructor. This implements semantically correct deep copying. In order to get the correct types, the CRTP interface is (a) inherited from the base class, and (b) reimplemented explicitly.

For IEquatable`1, we can just overload (note: not override!) the Equals method in the subclasses. In addition, we declare the interface — purely for documentation purposes. Finally, we could additionally override the base class Equals method and do either of the following:

  1. Bail out: public override bool Equals(Pet other) => throw new InvalidCastException();
  2. Implement polymorphic Equals; this would require an upcast, though.

In real code, I am tempted to go with (1) for most situations. But for some situations (2) is needed (e.g. for some operations on expression trees). However, I tend to implement those hierarchies completely differently anyway (namely, with a sum type; e.g. std::variant in C++). The solution then is to use visitors instead of subclass polymorphism.

As it stands, comparing cats and dogs (or apples and oranges) silently does the wrong thing here.

Notes

  1. I haven’t used C# in ages. I think the co-/contravariance of the interfaces is correct but I can’t be bothered to check.

  2. This should really be abstract to signal to implementers of the derived classes that it needs to be overridden. C++ allows this (pure virtual functions can have a default implementation).

  3. This is purely a convenience method to help me avoid having to write:

    ICloneable<Cat> catToClone = cat;
    Cat cc2 = catToClone.Clone();
    

    It’s a shame that C# doesn’t allow the following, more expressive signature, which would work in C++:

    static T As<T, U>(U obj) where U : T => obj;
    // Usage same as before, i.e. only specifying the first generic type argument.
    
  4. If C# allowed overload resolution based on return type, the following would work, too (again, C++ can do this — with some trickery):

    Cat cc3 = cat.Clone();
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment