Why can't we override convenience init?

import Foundation

class Parent {
    let parameter: String

    init(parameter: String) {
        self.parameter = parameter
    }

    convenience init(firstPart: String, secondPart: String) {
        self.init(parameter: firstPart + secondPart)
    }
}

class FirstChild: Parent {
    let secondParameter: Int

    init(secondParameter: Int) {
        self.secondParameter = secondParameter
        super.init(parameter: String(secondParameter))
    }

    override convenience init(parameter: String) {
        self.init(secondParameter: 4)
    }
}

class SecondChild: Parent {
    let secondParameter: Int

    init(secondParameter: Int) {
        self.secondParameter = secondParameter
        super.init(parameter: String(secondParameter))
    }

    override convenience init (parameter: String) {
        self.init(secondParameter: 4)
    }

    // Here: Initializer does not override a designated initializer from its superclass
    override init(firstPart: String, secondPart: String) {
        self.secondParameter = 4
        super.init(parameter: firstPart + secondPart)
    }
}

From swift documentation: The Swift Programming Language: Redirect

Conversely, if you write a subclass initializer that matches a superclass convenience initializer, that superclass convenience initializer can never be called directly by your subclass, as per the rules described above in Initializer Delegation for Class Types. Therefore, your subclass is not (strictly speaking) providing an override of the superclass initializer. As a result, you do not write the override modifier when providing a matching implementation of a superclass convenience initializer.

Taking into consideration these rules, I see that all rules are followed.

If we are talking about methods table, I suppose, it will look like(as per Automatic Initializer Inheritance rules, and table dispatch):

Parent
0x000 init(parameter:)
0x001 init(firstPart: , secondPart: )

FirstChild:
1x000 init(parameter:)
0x001 init(firstPart: , secondPart: )
1x002 init(secondParameter:)

SecondChild:
2x001 init(firstPart: , secondPart: )
1x002 init(secondParameter:)

We have two init phases in swift. And looks like here also no any issues. First phase ends in super class designated init.
I suppose, it can be related with something special regarding dispatch or objC. But it's just an assumption.

Question is: what is internal or low level explanation of restriction to override convenience init?

2 Likes

I'll give this a try…

Let's say you have parent class A, with child class (subclass) B.

  • In A, you have designated initializers A.D1 and A.D2, plus convenience initializers A.C1 and A.C2. For this example, assume that A.C2 calls "across" to A.C1, and A.C1 calls "across" to A.D1.

  • In B, you have designated initializers B.D1 and B.D2, either as overrides or by default inheritance.

Now, let's say you could add an override B.C1, and you choose make it call "across" to B.D2, which calls "up" to A.D2.

That's the setup. Let's look at what happens if you create an instance of B using the inherited C2 initializer. C2 would call "across" to B.C1 because of the override, then "across" to B.D2, then "up" to A.D2. Neither A.C1 nor A.D1 would be called.

That causes a problem. A.C2 is written under the assumption that it's going to go through A.D1 at some point during initialization. However, with the override, it's never going to do that. It will go though A.D2 instead, possibly failing to initialize the instance's state properly for the C2 scenario.

Of course, if you are the author of class A (and especially if you are also the author of class B), you might be able to arrange your code so that it won't matter to C2 whether it goes through D1 or D2. However, you can't really provide that guarantee when A is in a framework and B is defined by an app using that framework.

This is not a theoretical problem. It was a real-life problem with Apple framework classes in Obj-C, where you might accidentally create subtle, hard-to-debug bugs by making the wrong assumptions about which initializers did what.

The purpose of the "across" rule for convenience initializers is to make it safe for them to assume they know which designated initializer will be called, and with what parameters.

If convenience initializers could override parent convenience initializers, that safety can't be guaranteed.

1 Like

Hi, @QuinceyMorris, thanks a lot for your answer.

  1. Designated init must ensure that all properties are initialized properly.
  2. From your example: for B and for A designated inits are called. And we assume that this chain possibly fails to initialize the instance's state properly. So, finally, it's the equivalent of "calling designated initializers causes incorrect instance's state".(Correct me if I'm wrong).

You said, that calling A.D2 directly from child without calling A.C2 can cause incorrect state. It means, that calling A.D2 from everywhere can cause incorrect state. And it means inits in A class are malformed.

So, the idea about confusing and possibly incorrect behavior, can be an answer. But, honestly I'm not sure, that it's a good idea to consider a case that developer can do a mistake. Mistake can be done always. But code analyzer isn't the most powerful thing to prevent it. As far, as we know swift compiler allows creating A.C1, that calls A.C2, and A.C2 which calls A.C1.

So looks like here should be a little bit more serious reasons to make this compilation restrictions.

You are correct that all properties of A would be initialized, regardless of which designated initializer was called. But there are other subtler issues.

For example, suppose class A did something with a URL, and had 2 "modes", one for a URL to a local file, and one for a URL to a server file. It's possible that D1 could be the initializer for local mode, and D2 could be the initializer for server mode.

In that case, it matters whether A.D1 or A.D2 is called (and it may matter what parameters are passed to A.D1 or A.D2). Changing the sequence of calls that should go via D1 to a sequence of calls that go to D2 could break the class behavior catastrophically.

The simplest example of breaking would be if A.D2 aborted the app if it was passed a local URL. That's not a completely implausible scenario, I think.

1 Like

@QuinceyMorris, thanks for your opinion. Good point, I think.
But you are still talking about: "Imagine, developer write code that abort application. It doesn't matter where developer wrote this code. I can abort application everywhere, and also it isn't too hard to write API that can be misused."

I'd like to see more technical reasons. Because it's a compilation error. And compiler it's not a tool to check errors in program logic. Compiler shouldn't tend to prevent such mistakes. It seems to be fantasy. The work of the compiler should be based on language rules. And the goal of this post is to understand rules that make overriding convenience impossible.

I would say that this behavior is a language rule. It's documented as part of the language. (You quoted the link in your first post.)

It's not illogical to override a convenience initializer. Instead, it's unsafe to override a convenience initializer — in the context of all the other rules about designated and convenience initializers, and the two-passes of initializations, and order in which initialization occurs.

Many parts of the Swift language make safety guarantees. This is one of them.

Actually, the compiler should be a tool to check logic errors, when it can, shouldn't it? For example, look at in this code:

if a == 1 {
  print("1")
}
else if a == 1 {
  print("also 1")
}

That's definitely a logic error — the else branch cannot be reached. Wouldn't you want the compiler to tell you that?

1 Like

@QuinceyMorris tell me then, please: which rule is violated?

As you can see, in this case compiler doesn't complain. And I expect it. Because it's my mistake.
Once again, I got your point, thank you. Now, I'd like to figure out what are technical reasons.

Not every rule has a "technical" reason, in the sense I think you mean.

For the rule that says B.C1 cannot override A.C1:

  1. There is a technical reason why B.C1 must not call "up" to A.C1 (e.g. by invoking super.C1): that would result in a designated initializer being called 0 times or 2 times. Both of those outcomes would be illegal.

  2. There is a safety reason why A.C2 must not call "down" to B.C1 (e.g. via an override): that would make it unpredictable to the author of A which of A's designated initializers would be called. Experience with Obj-C has shown that this unpredictability is dangerous, so Swift has chosen to make this illegal, too.

1 Like