Friday, June 6, 2014

Remove features for greater power, aka: Swift and Objective-C initializers

One of the things I find curious is how Apple's new Swift language rehashes mistakes that were made in other languages. Let's take construction or initializers.

Objective-C/Smalltalk

These are the rules for initializers in Smalltalk and Objective-C:
  1. An "initializer" is a normal method and a normal message send.
  2. There is no second rule.
There's really nothing more to it, the rest follows organically and naturally from this simple fact and various things you like to see happen. For example, is there a rule that you have to send the initial initializer (alloc or new) to the class? No there isn't, it's just a convenient and obvious place to put it since we don't have the instance yet and the class exists and is an obvious place to go to for instances of that class. However, we could just as well ask a different class to create the object for us.

The same goes with calling super. Yes, that's usually a good idea, because usually you want the superclass's behavior, but if you don't want the superclass's behavior, then don't call. Again, this is not a special rule for initializers, it usually follows from what you want to achieve. And sometimes it doesn't, just like with any other method you override: sometimes you call super, sometimes you do not.

The same goes for assigning the return value, doing the self=[super init]; dance. Again, this is not at all required by the language or the frameworks, although apparently it is a common misconception that it is, a misconception that is, IMHO, promoted by careless creation of "best practices" as "immutable rules", something I wrote about earlier when talking about the useless typing out of the id type in method declarations.

However, returning self and using the returned value is a useful convention, because it makes it possible for init methods to return a different object than what they started with (for example a specific subclass or a singleton).

Swift initializers

Apple's new Swift language has taken a page from the C++ and Java playbooks and made initialization a special case. Well, lots of special cases actually. The Swift book has 30 pages on initialization, and they aren't just illustration and explanation, they are dense with rules and special cases. For example:
  1. You can set a default value of a property in the variable definition.
  2. Or you can set the default value in an initializer.
  3. Designated initializers are now a first class language construct.
  4. Parameterized initializers have local and external parameter names, line methods.
  5. Except that the first parameter name is different and so Swift automatically provides and external parameter name for all arguments, which it doesn't with methods.
  6. Constant properties aren't constant in initializers.
  7. Swift creates a default initializer for both classes and structs.
  8. Swift also creates a default member wise initializer, but only for structs.
  9. Initializers can (only) call other initializers, but there are special rules for what is and is not allowed and these rules are different for structs and classes.
  10. Providing specialized initializers removes the automatically-provided default initializers.
  11. Initializers are different from other methods in that they are not inherited, usually.
  12. Except that there are specific circumstances where they are inherited.
  13. Confused yet? There's more!
  14. If your subclass provides no initializers itself, it inherits all the superclass's initializers
  15. If your subclass overrides all the superclass's designated initializers, it inherits all the convenience initializers (that's also a language construct). How does this not break if the superclass adds initializers? I think we've just re-invented the fragile-base-class problem.
  16. Oh, and you can initialize instance variables with the values returned by closures or functions.
Well, that was easy, but that's probably only because I missed a few. Having all these rules means that this new way of initialization is less powerful than the one before it, because all of these rules restrict the power that a general method has.

Particularly, it is not possible to substitute a different value or return nil to indicate failure to initialize, nor is it possible to call other methods (as far as I can tell).

To actually provide these useful features, we need something else:

  1. Use the Factory method pattern to actually do the powerful stuff you need to do ...
  2. ...which gets you back to where we were at the beginning with Objective-C or Smalltalk, namely sending a normal message.
Of course, we are familiar with this because both C++ and Java also have special constructor language features, plagued by the same problems. They are also the source of the Factory method pattern, at least as a separate "pattern". Smalltalk and Objective-C simply made that pattern the default for object creation, in fact Brad Cox called classes "Factory Objects", long long before the GOF patterns book.

So with all due respect to Michael A. Jackson:

First rule of baking programming conventions into the language: Don't do it!
The second rule of baking programming conventions into the language (experts only): Don't do it yet!


p.s.: I have filed a radar, please dup
p.p.s.: HN

6 comments:

Chris Lattner said...

Marcel, I totally agree with your simplicity goal, but this isn't practical unless you are willing to sacrifice non-default initializable types (e.g. non-nullable pointers) or memory safety.

Marcel Weiher said...

Chris, thanks for your comment. It deserves a longer answer.

alastair said...

The point that worries me the most is #15 (I think you’re right that this creates a new form of fragility). I think I’d agree with Chris that most of the others are a legitimate part of the trade-off between flexibility and the ability to provide certain language features and/or to perform certain compile-time optimisations.

Marcel Weiher said...

Chris,

The memory safety argument is completely bogus, many languages such as Smalltalk provide perfect memory safety without special-cased initializers.

That simplicity is the part that is "impractical" is a null argument, i.e. you are simply stating that the features you prefer are non-negotiable and everything else is subordinate.

Let me turn it around: Chris, I totally agree with your goal of initializable types, but it is just not practical unless you are willing to sacrifice simplicity, parsimony and power (and ignore the fact that it doesn't actually work).

However, as I wrote, the fact is that special initializers have been tried and found to actually not work in practice, meaning users have to resort to the Factory Pattern, at which point your safety guarantees go out the window anyway. So you have arrived at the same place that you would have had you not introduced special-case initializers, except with a lot more unnecessary complexity.

Alastair, you make a good point, but Chris does not present this as a trade-off, with benefits and costs, but simply as an absolute.

Edward Lewis said...

You can call other functions, methods from with initialisers. You just have to do it after its variables are finished initialising and any super classes have finished initialising. At that point the object is fully initialised (memory wise) before the init function has completed.

There are certainly a lot of rules for specific cases for initialisers, and while I love me some named parameters, it would be nice to see some more consistent behaviour across functions, methods and initialisers. But if those rules are not implements at the language rules, then they still need to be implemented at a higher level, which means the programmer still has to worry about, and probably make a few of them up themselves. That results in inconsistent behaviour across codebases which ultimately increases complexity. At leas this way, there are one set of rules for initialisers that everyone must follow. If your not willing to compromise on simplicity and power, then you should probably be writing all your code in assembly.

What is a language if not a baked list of conventions?

Nik-13 said...

I came from Java and was puzzled by the bizarre complexity of Obj-C initializers. Swift is more like Java so it makes more sense to me. Yes initializers are special cases, as they should be - there are many bugs related to initialization.
The frequency with which I've wanted initializers that can fail is less than 1 in a thousand, if I had to take a guess. In Java, I'd just throw an exception in that case. It makes no sense to do what Obj-C does which is to treat failure to initialize as a case that always need to be kept in mind.