Concept: `~Initializable`

I was having a discussion with someone about global actors. I had bought up how it's strange that the compiler allows you to instantiate them more than once. They suggested that this could actually be a more general problem and came up with a fascinating idea: ~Initializable.

class Singleton: ~Initializable {
  static let shared = Singleton()

  private init() {
  }
}

I think the idea here is that the init of this type is uncallable anywhere other than in the initializer expression for a static let shared member.

On the one hand, I don't know if this would be solving a particularly serious problem. Because a private init goes quite far here. However, such a mechanism could allow the compiler to assume that any value of type Singleton must necessarily be the instance Singleton.shared.

This particular assumption could have some interesting implications for global actors. Today, it's possible to make something generic over isolation with an isolated parameter. And while that does work for global actors, the "global actorness" is lost.

I think this might make it possible to do things like this:

protocol GloblActor: ~Initializable {
  // ...
}

extension GlobalActor {
  static func assumeIsolated<T: Sendable>(
    _ operation: (_ isolation: isolated Self.ActorType) throws -> T
  ) rethrows -> T {
    // ...
  }
}

I think that such a tool could be used to make code like this work as well:

func genericActor<A: Actor>(_ actor: A, _ fn: (isolated A) -> Void) {
  Task {
    await fn(actor)
  }
}

@MainActor
func mainOnly() {}

func useIt() {
  genericActor(MainActor.shared) { _ in
    // error: it cannot be proven that "MainActor" the type == MainActor.shared
    mainOnly()
  }
}

I personally tend to avoid singletons, actor or otherwise. But they are in wide use, and a core part of the concurrency system. So, I thought this was interesting enough to see what others thought about it.

2 Likes

From a purely “spelling-at-the-point-of-use” perspective, I think it would be better if we could write let foo = Singleton() and have it resolve to the shared instance instead of allocating a new one.

I don’t know what features would be needed to make that work, perhaps allowing convenience initializers to reassign self, but if we can do it then it would let people use the standard “call an initializer” spelling just like they do for every other type.

2 Likes

Huh, this is an interesting suggestion.

In my mind “making a new instance” and “referencing a shared instance” are very distinct in many ways. What advantages do you see by having the same syntax for both?

1 Like

There have definitely been times where I wished that writing T() could do something other than allocating a new instance of T(). I don't remember the exact use case, though.

But I don't think it's just an implementation problem to decide if we want to do that. We also need to decide conceptually whether Swift's initialization model is

  1. T.init(...) returns a fully independent instance of T
  2. T.init(...) just returns some instance of T without specifying independence

Right now, it means #1. For value types, the returned value is always an independent copy even if you do self reassignment. For reference types, calling init is always an allocating init (AFAIK). That predictability is nice for reasoning about how code works in terms of allocations.

Here's an alternate idea if we want to pursue this: static callAsFunction. We could say that if a type declares a static func callAsFunction(...) -> T (or T?/T!, supporting throws/async etc.), then writing T(...) or T.self(...) means callAsFunction instead of init, and if you want to call the initializer (for example, inside the implementation of callAsFunction), you have to explicitly write T.init(...). This lets you get the syntax you want at the call site without having to change what the meaning of initialization is in the language; the implementation of a static callAsFunction could return an existing instance, allocate a new one, or something else.

7 Likes

I think this is an interesting idea. Especially to make this behavior explicit. This can help to make your types open for extensions but closed for additional initializations, in general not only for [global] Actors.

I think it would be reasonable for Swift’s language model to allow class initializers to produce self in an arbitrary way.[1] That would necessarily allow such an initializer to return a singleton. But I also think it would be poor API design to make init() always return a singleton, because that’s not what init() idiomatically means — .shared is a much clearer way to write that. Collapsing semantically meaningfully distinctions onto the same syntax is not a good thing to do!

The exception, I suppose, is if idiomatic use of the type doesn’t depend on object identity; you can see this pattern in ObjC’s immutable data structure classes, e.g. NSArray(). But if that’s true, it strongly suggests that the type just shouldn’t be a class in the first place, which is why Swift makes all of those value types.


  1. You could only use such an initializer to create a complete object — i.e. no delegating to it from an initializer that’s expected to initialize a base subobject. But that overlaps with the rules for convenience, and perhaps this would just be an implementation option for those. ↩︎

10 Likes

RBI currently assumes initializers return disconnected instances.

So I think either

  • initializers which don't use the standard mechanisms would still be required to return disconnected instances (maybe it would have to be limited to Sendable types, because I don't see how it could otherwise be checked); or
  • the compiler would need to know whether each initializer was returning a disconnected instance or not; that is, "this initializer does not use standard mechanisms" would have to be part of the ABI.
1 Like

From previous discussions about factory initializers, the main benefit of returning an arbitrary instance from an initializer is that you can return a subclass. So for instance the Car(kind: “sedan”) can return a SedanCar instance. Currently you need to model this as a static function.

You could also use such a feature to return a global shared instance, but I agree with @John_McCall that it makes the intent unclear in general, although it may makes sense if you model a value using a class.

I think it could make sense to have an attribute for classes that never need any ARC operation, or in other words, where all instances are immortal. But I also think it’d make more sense to have an @immortal attribute for that.

The syntax ~Initializable kind of sounds like “not-initializable”, but it’s a lie (you obviously need to initialize it once) and it’s not exactly what ~ means in other contexts like ~Copyable (where it means don’t assume Copyable).

2 Likes

These are two good distinctions. In the context here (a very early stage idea), what kind of impact would these have?

sounds like someone is suggesting sending and nonsending inits to me

This is already “wrong” if the result has a field that’s a shared object (a Sendable object retrieved from a global Mutex, maybe). Whether or not every initializer returns a unique instance, the region analysis should be the same as for a factory method.

(As a bonus, ObjC can return existing objects from initializers, and swiftc will handle that. You just can’t define such initializers in Swift (yet?).)

On reflection, I think this is right — initializers aren't distinguished in this sense; an initializer which takes arguments will merge those arguments' regions & return a new object in that region; that behavior is the same as a factory method.

I was thinking of no-arg initializers specifically when I said they returned disconnected objects, and a no-arg factory method would be analyzed in the same way.

If we allowed you to overload T() to return an existing instance, a pure-swift implementation of that could only get that instance from a global, and that would already imply either that T is Sendable, or that the instance is disconnected, so there's not actually a problem with it.

This (and similar things like factory methods & singleton accessors) can and will mess with RBI, eg. UserDefaults being non-Sendable, but UserDefaults.shared being non-isolated, messes with both RBI and concurrency-safety already. But that's a case of "the API is unsafe and mis-imported into Swift", not a Swift problem specifically.

1 Like

You can return existing objects from convenience initialisers in Swift using a protocol extension: Allow `self = x` in class convenience initializers

5 Likes