Several things occurred to me while reading it. I want to focus on the technical issue, but I can't help but mention a couple ancillary things that got me here. Before I start, I want people to keep in mind that this post is not meant to be a criticism of anyone. It is a criticism of certain ideas.
It bothers me that the "accepted answer" for the posted C# question (the question was: "Is there a more elegant way to do this?") on stack overflow basically boiled down to: "Try Haskell".
Really? That's not an acceptable answer. Not just because I disagree with it. But because it didn't actually answer the question. And do you really think the original poster--who marked that as the accepted answer--is now using Haskell to solve his problem? I doubt it.
Accompanying the answer was a link to a blog post, providing further details as to why people should avoid using this pattern.
It's an interesting read. And it's a valid opinion. And I couldn't disagree more.
Here's the problem, as envisioned by Eric Lippert:
public abstract class Animal<T> where T : Animal<T> { public abstract void MakeFriends(T animal); } public class Cat : Animal<Cat> { public override void MakeFriends(Cat cat) { } }
The problem with this, according to Eric, is that we can still create a class that would violate our intentions, such as this:
public class EvilDog : Animal<Cat> { public override void MakeFriends(Cat cat) { } }
Now an EvilDog can make friends with a Cat!
But this isn't a flaw in the pattern!
It's no different than this:
public class Cat { public void MakeFriends(Cat cat) { } } public class EvilDog : Cat { }
Oh no! Even without using generics and the curiously recurring template pattern we can create an EvilDog that can make friends with a Cat!
Anyway...
I use the pattern quite regularly. It can be an enormously helpful tool. Like anything, it can be misused.
If it's imperative that T be the defined subclass, and it's seen as likely that developers won't realize that, you can mitigate that problem by doing something as simple as:
protected Animal() { if (!(this is T)) throw new ApplicationException("Can't do that!"); }
Problem (if there ever was one) solved.
Your solution to the problem of the static type system being too weak to represent the desired constraint is to abandon some static type checking and impose a runtime cost in the constructor.
ReplyDeleteLet's assume for the sake of argument that it is *acceptable* to abandon static typing and go with a runtime-checked solution. Given that assumption, is the proposed check in the constructor on T the right solution for the problem? It is certainly not the solution I would choose.
If it is acceptable to you to use runtime type checking -- and I can see how that might be practical -- then *why continue with the curious pattern*??? The *entire point* of the curious pattern is to impose the desired restriction *in the static type system*. If you're willing to abandon the static type system then there's no *need* to continue with the curious pattern, as it exists in this example entirely as a hamfisted and bizarre attempt to make the static checker happy.
If you want to impose the restriction that an animal makes friends only with others of its kind, and doing runtime type checking is an acceptable solution, *then the place to do runtime type checking is in the base implementation of MakeFriend*.
If you are willing to abandon static type checking then why use the curious pattern in the first place? What value does it add? All it does is create confusion.
I'm not abandoning any static type checking. I'm ADDING some runtime type checking to the static type checking that already exists in the pattern.
ReplyDeleteIs the contructor the right place for the check? Of course it is!
I am solving the GENERAL problem of restricting T to be the defining class (or in this case, at least being an ancestor of the defining class).
I think you are stuck thinking specifically about your example instead of the pattern in general. (I wouldn't use the pattern to implement your Cat / EvilDog model at all. And in that case, I would agree that putting some checking in the MakeFriend method would make sense rather than the constructor.)
You make a valid point that the pattern doesn't fully restrict T as some people might assume. I don't think that's a good enough reason to abandon the pattern. Especially when you can solve that issue by restricting it the way I have above.
It's a bit late but I don't see the argument here. The idea of using the type system this way is more to protect the consumer of the objects at times than it is to protect the implementer. Sure you can still implement this - I would love a this constraint to be available as a constraint if it is possible.
ReplyDeleteAs a probably bad example say I have a factory and I only want it to accept configurations that have the parametrised type of the factory (i.e configurations for the factory). I think having this self referencing constraint prevents people from chucking any configuration object as a parameter while allowing all configurations to share the same interface.
That way I've constrained the parameters to my factory's methods. It works even better when the argument has a parametrised type as well. I can then use the interfaces rather than the hardcoded classes in my code.
In the end some problems lean themselves to this way of thinking - otherwise different people wouldn't have come up with this pattern on their own. I personally used this pattern some time ago, then found out the name for it later when trying to model the problem in the best way possible. While there is always other ways of writing things a language should aim to allow the programmer to capture the intent of the programmer in the best way possible.