Swift compile time dynamic type property

264 Views Asked by At

Is there any syntax can make this work? I need a property can determine its type in the compile time.

protocol P {}

struct A: P {
   var onlyAHas: String
}

struct B: P {
   var onlyBHas: String
}

var ins1: any P = A()
var ins2: any P = B()

ins1.onlyAHas = "a only"
ins2.onlyBHas = "b only"
1

There are 1 best solutions below

3
Chip Jarred On

Before getting to the solution, let's break down what any means, and while we're at it, we'll include some as well:

When you write:

var ins1: any P = A()

You are telling the compiler that you want to use ins1 as P. It's the protocol oriented equivalent of this OOP code:

class Base {
   var baseProperty: String? = nil
}

class Concrete: Base {
   var concreteProperty: String? = nil
}

let obj: Base = Concrete();

obj.baseProperty     = "Some value" // <-- This is fine
obj.concreteProperty = "Some value" // <-- This is an error

This code tells the compiler that obj is a Base. You can assign it from a Concrete, but because that's a subclass of Base, but obj is still known locally as a Base not as a Concrete, so it can't access the properties of Concrete that weren't inherited from Base.

It's the same in your example. ins1 is known locally as a P not as an A, and P doesn't have an onlyAHas property.

You'd get similar behavior with some instead of any. There are a few differences between the two, but let's just talk about the main one:

some tells the compiler that it will be a type that it can resolve to one specific concrete type, but that it should enforce the abstraction to the protocol in source code. This allows it to generate more efficient code internally, because knowing the concrete type allows the compiler to call the concrete's implementation directly instead of going through its protocol witness table, which is the protocol-oriented analog of a "vtable" in OOP, so the effect is like in OOP when the compiler devirtualizes a method call because despite the syntax, it knows the actual concrete type. This avoids the runtime overhead of dynamic dispatch while still letting you use the abstraction of the existential type... well it's more like it requires you to use the abstraction of the existential type than lets you, because from a source code point of view, the abstraction is enforced.

any also enforces the abstraction, but it goes the other way in terms of the kind of optimizations the compiler can do. It says that the compiler must go through the protocol witness table, because, as the keyword suggests, its value could be any concrete type that conforms to the protocol, even if the compiler could determine that it's actually just one specific type locally. It also allows relaxation of some rules regarding using the protocol as a type when it has Self and associatedtype constraints.

But either way, you are telling the compiler that you want to use ins1 as a P and not as an A.

The solutions

There are a few solutions, actually:

Downcasting

The first is to downcast to the concrete type, as was suggested in comments by Joakim Danielson:

if var ins1 = ins1 as? A {
    ins1.onlyAHas = "a only"
}

Downcasting is a code smell, but sometimes is actually the clearest or simplest solution. As long as it's contained locally, and doesn't become a wide-spread practice for using instances of type, P, it might be fine.

However, that example does have one problem: A is a value type, so the ins1 whose onlyAHas property is being set is a copy of the original ins1 you explicitly created. Having the same name confuses it slightly. If you only need the change to be in effect in the body of the if, that works just fine. If you need it to persist outside, you'd have to assign back to the original. Using the same name prevents that, so you'd need to use different names.

Execute concrete-specific code only at initialization

This only applies if the concrete type just configures some things for the protocol up-front, and thereafter protocol-only code can be used:

var ins1: any P = A(onlyAHas: "a only")

// From here on code can only do stuff with `ins1` that is defined in `P`

Or your could delegate the initialization to a function that internally knows the concrete type, but returns any P.

func makeA(_ s: String) -> any P
{
    var a = A()
    a.onlyAHas = s
    return a
}

var ins1 = makeA("a only");

// From here on code can only do stuff with `ins1` that is defined in `P`

Declare protocol methods/computed properties that do the work.

This is the usual way to use protocols. Declaring a method in the protocol is similar to declaring a method in a base class. Implementing the method in a conforming concrete type is like overriding the method in a subclass. If you don't also provide a default implementation in a protocol extension, the protocol will enforce that conforming types implement the protocol - which is a big advantage over the OOP approach.

protocol P {
    mutating func setString(_ s: String)
}

struct A: P 
{
   var onlyAHas: String

   mutating func setString(_ s: String) {
       onlyAHas = s
   }
}

struct B: P 
{
   var onlyBHas: String

   mutating func setString(_ s: String) {
       onlyBHas = s
   }
}

var ins1: any P = A()
var ins2: any P = B()

ins1.setString("a only") // <- Calls A's setString
ins2.setString("b only") // <- Calls B's setString

I'm doing this with a setString method, but you could certainly use a computed variable in the protocol to do the same thing, and that would be more "Swifty." I didn't do that just to emphasize the more general idea of putting functionality in the protocol, and not get hung up on the fact that the functionality in question happens to be setting a property.

If you don't need all conforming types to be able to set a String, one solution is to provide a do-nothing default implmentation in an extension on P:


protocol P {
    mutating func setString(_ s: String)
}

extension P
{
    mutating func setString(_ s: String) { /* do nothing */ }
}

// Same A and B definitions go here

struct C: P { }

var ins3: any P = C();

ins1.setString("a only") // <- Calls A's setString
ins2.setString("b only") // <- Calls B's setString
ins3.setString("c only") // <- Calls setString from extension of P

Most often though, setting/getting some concrete property is an implementation detail of doing some task that varies with the concrete type. So instead, you'd declare a method in the protocol to do that task:

protocol P
{
    mutating func frobnicate()
}

struct A
{
    var onlyAHas: String

    mutating func frobnicate()
    {
        // Do some stuff
        onlyAHas = "a only"
        // Do some other stuff that uses onlyAHas
    }
}

B would be defined similarly doing whatever is specific to it. If the stuff in comments is common code, you could break it down into prologue, main action, and epilogue.

protocol P
{
    mutating func prepareToFrobnicate()
    mutating func actuallyFrobnicate() -> String
    mutating func finishFrobnication(result: String)
}

extension P
{
    /*
    This method isn't in protocol, so this exact method will be called;
    however, it calls methods that *are* in the protocol, we provide 
    default implementations, so if conforming types, don't implement them, 
    the versions in this extension are called, but if they do implement 
    them, their versions will be called.
    */
    mutating func frobnicate()
    {
        prepareToFrobnicate()
        finishFrobnication(result: actuallyFrobnicate());
    }

    mutating func prepareToFrobnicate() {
        // do stuff general stuff to prepare to frobnicate
    }

    mutating func actuallyFrobnicate() -> String {
        return "" // just some default value
    }

    mutating func finishFrobnication(result: String) {
        // define some default behavior
    }
}

struct A
{
    var onlyAHas: String

    mutating func actuallyFrobnicate() -> String
    {
        // Maybe do some A-specific stuff

        onlyAHas = "a only"

        // Do some more A-specific stuff

        return onlyAHas
    }
}

struct B
{
    var onlyBHas: String

    mutating func actuallyFrobnicate() -> String {
        "b only"
    }

    mutating func finishFrobnication(result: String)
    {
        // Maybe do some B-specific stuff

        onlyBHas = result

        // Do some more B-specific stuff
    }
}

var ins1: any P = A()
var ins2: any P = B()

ins1.frobnicate();
ins2.frobnicate();

In this example, the frobnicate in the protocol extension is called, because it's defined only in the protocol extension.

For ins1, frobnicate then calls the extension's prepareToFrobnicate, because even though it's declared directly in the protocol, A doesn't implement that and a default implementation is provided in the extension.

Then it calls A's actuallyFrobnicate because it's defined directly in the protocol, and A does implement it, so the default implementation isn't used. As a result the onlyAHas property is set.

Then it passes the result from A's actuallyFrobnicate to the finishFrobnication in the extension, because it's defined directly in the protocol, but A doesn't implement it, and the extension provides a default implementation.

For ins2, frobnicate still calls the default prepareToFrobnicate, and then call's B's implementation of actuallyFrobnicate, but B's implementation doesn't set its onlyBHas property there. Instead, it just returns a string, which frobnicate passes to finishFrobnication, which calls B's implementation, because unlike A, B provides its own implementation, and that's where B sets it.

Using this approach, you can simultaneously standardize the general algorithm of a task like frobnicate, while allowing for dramatically different implementation behavior. Of course, in this case, both A and B just set a property in their respective concrete types, but they do it at different phases of the algorithm, and you can imagine adding other code, so that the two effects really would be very different.

The point of this approach is that when we call inst1.frobnicate(), it doesn't know or care about exactly what inst1 is doing internally do accomplish it. The fact that it internally sets the onlyAHas property in the concrete type is an implementation detail the calling code doesn't need to be concerned with.

Just use the concrete type

In your code example, you are creating and using ins1, and ins2 in the same context. So they could just as easily be defined like this:

var ins1 = A()
var ins2 = B()

ins1.onlyAHas = "a only" // <- This is fine because ins1 is an A
ins2.onlyBHas = "b only" // <- This is fine because ins2 is a B

If you have some function, munge that you want to do on both A and B, you can define it terms of the protocol.


func munge(_ p: any P)
{
   // In here you can only use `p` as defined by the protocol, `P`
}

If munge needs to do things that depend on concrete-specific properties or methods, you can use one of the previously described approaches...

OR...

If you know for sure that you only will ever have a small number of concrete types conforming to P, which admittedly is sometimes impossible to really know, but occasionally you do, then you can just write specialized overloaded versions of munge for each concrete type:

func munge(_ a: A) {
    // Do `A`-specific stuff with `a`
}

func munge(_ b: B) {
    // Do `B`-specific stuff with `b`
}

This kind of regresses to older solutions to problems like this. When I say it's an old solution, I'm referring to the fact that even back when the C++ compiler was just a preprocessor that converted C++ source code to C source code which would then be compiled, didn't have templates, and standardization wasn't even on the horizon, it would let you overload functions. You can do that with Swift too, and it's a perfectly valid solution. Sometimes it's even the best solution. More often it leads to code duplication, but it's in your toolbox to use when it's appropriate.