Eric Lippert has a very nice series outlining some common pitfalls of solving a seemingly simple problem in C# (and object oriented programming in general), that of encoding end-user requirements into the type system via inheritance. I thought it was very though provoking and well done. In the end, the solution he comes up with left me nonplussed, so I wanted to ponder on it here.
At the start, I want to admit I’m not an expert in these topics. I’m writing this as much to make sure I understand Eric’s post as I am to publish disagreements. I think this quote from the end of the 4th installment gives a good, terse overview of what happened and where we wound up:
We started this series trying to represent the rule “a Player has a Weapon but a Wizard is restricted to using a Staff”, and we pretty much failed to come up with a good way to do that; representing restrictions is hard in class-based inheritance. And lately we’ve tried to represent the idea of “dispatch to special logic for special cases, use regular logic for regular cases”, and haven’t gotten very far there either. It seems that our tools are resisting us; maybe we’re using the wrong tools, or maybe we’re coming at the problem in the wrong way.
In the fifth installment, the solution was to setup a new Rule class just to hold this “business” logic, and to move the behavior for the Player, Wizard, Warrior, and Weapon classes off into object instances created from that class. Now we aren’t involving the type system or inheritance at all in our solution.
We have no reason to believe that the C# type system was designed to have sufficient generality to encode the rules of Dungeons & Dragons, so why are we even trying?
That’s pretty hard to argue with, right? So what’s my beef?
- I don’t want a domain-specific language for rules. At least, not one that I implement myself without tooling support.
- I want invalid rules and rule combinations to be detected at compile-time, not run-time.
I’ll Call It… The Wheel 2!
The final solution of generalizing the rules of the game into a separate class points down the path of reinventing the wheel. The wheel here being a general-purpose programming language.
Users ask for more flexibility in rules, access to other pieces of information, the ability to have bigger impacts on the system, and slowly but surely as you add the features in, you wind up with something really complex. Indeed, Eric points to Inform7 as an example of an existing system created that might solve the problem in his series, but you can clearly see that it’s a full-fledged language with its own best practices and pitfalls.
Once we have a new programming language, we have syntax errors, compiler errors, or worse. We need a way to tell the user the custom rule they wrote is screwed up, and hopefully point to how they can fix it. When I already have a great language tool-chain for C#, including not just parsing and compilation with nice error messages, but syntax-highlighting, auto-complete, unit testing capabilities, code analysis, and the rest, why would I start over?
Peter Cooper’s response in the comments at Eric’s blog was along these lines, too. I feel like you see this over and over in software development. Everyone means well when they abstract out the “code” into “data”, and at first it’s a nice, simple idea that works. Then it keeps going and your language becomes Turing-complete, and a year or two down the line someone using that language comes up with a great idea to simplify things involving moving “code” out into “data” and we all feel like we’re watching Inception. This really, truly happens all the time. A language being Turing-complete is not inherently a bad thing, but it indicates a level of complexity of use that requires more than a little additional up-front work to maintain ease-of-use for those end-users we wrote it for in the first place.
Rule 110 is a simple enough system to suggest that naturally occurring physical systems may also be capable of universality— meaning that many of their properties will be undecidable, and not amenable to closed-form mathematical solutions.
Anyways, all code is data, not just the particular code we decide should be easy to edit after we hit the compile button. The fact that there isn’t an easy way to handle the code created after we’re done with the “important” part of software development isn’t strictly a failing of C#. Few languages and platforms support the kind of interactive code-as-data architecture and components that would allow us to use, say, an IDE widget inside our app to generate well-made, parsed, compiled, analyzed, and tested code that we can then use inside the same application. Maybe they should.
No matter how the rules get created – if it’s in code by a developer, or by user-created entries pulled from an external data source, I’d like to know as soon as possible that the rules are inconsistent. I initially wanted to argue that the type type system should be a good place for us to do the kind of “rules” work Eric describes. But the more I thought about it, the less sure of that I became.
To me, the type system is a magic oracle that can automatically and quickly tell me when I’ve made a mistake. It’s the tool I know that has the effect I like, so it’s disappointing when I’m told that I can’t use it in this case, and that’s a big reason the final result Eric gave left me feeling deflated. Thinking about it more, if my type system was sufficiently complex enough to allow me to encode these rules, wouldn’t I just be pushing off that problem of “Turifying” it there? Having a type system that’s capable of solving complex problems on its own sounds like something a Haskell programmer would come up with (or maybe a Scala programmer).
Actually, there are people encoding interesting capabilities into types – ideas like dependent types show up in F* (a version of F#) to validate functional correctness, and coeffects are about adding context into our type systems to make sure resources are correctly made available when needed and hidden when not needed.
Supposing we don’t want to complicate our type system, are there still other ways we could get rapid feedback on a bad set of rules being setup?
- Static analysis, if it’s clever enough, and if our rule system is simple enough, could possibly find issues.
- Code contracts seem like they’d offer some possible help to prevent taking incorrect actions.
- Automatic test generation could help illuminate issues early on.
C# supports all of these to varying degrees.
At this point I’m definitely out of my depth as far as identifying the proper solution precisely, either in C#’s version of OOP or in a wholly new language design, but I feel good that I’ve given clarity to some of my concerns with the final Wizards and Warriors rules setup.