Where are protocols going?

Every time I get to the point of generalizing some service usage into something that I can have real world values in, and then a mock or other implementation there of, I think about protocols and try, again, for the Nth time, to use them and not run into walls.

I have not really ever understood why Protocol is any different from Java interfaces. Why do protocols have so many short coming limitations on how and when they can be used. In particular, I have ObservableObject on an existing “service object” and I want to be able to use DI for its value and using a protocol would make that a really flexible thing to do. However, I cannot put ObservableObject on a protocol, and then have that become part of how the object implementation works. I don’t know how code generation is happening, but suspect that what the problem actually is, is that we still have Obj-C and C/C++ weaved in behind the scenes in such a way that Protocol is not really a simple declaration thing like Java’s Interface is.

My question is, will protocols ever become useful as a general tool, or are they really just a way to put a name on a collection of details that you can’t really abstract into use with all the other places that struct and class are actually used?

Can you please clarify what do you mean by saying these words.

Protocols are useful as general tool since first Swift version, and in every major Swift version they become more and more powerful. All collections, all numeric types, RxSwift / Combine, SwifUI / UIKit, Foundation – all of this is made using protocols.

It might be better to inspect some code sample where you have difficulties in expressing something with protocols.

1 Like

Your suspicions are fairly far off base. Most of the ways protocols are different from interfaces are ways that they're more powerful, not less. For example there's no type erasure going on, unlike Java, and associated types can do some things that generic interfaces cannot (e.g. you cannot do something like Swift's Collection.Index type in Java without making clients specify the Index type when using the interface). However, this additional flexibility comes with different usage patterns; for example Swift protocols are usually best used as constraints on generic parameters rather than as types in and of themselves. This is not an implementation limitation, it's an inherent result of the more flexible concept.

The entire standard library is basically built around protocols, which seems to be working quite well, so we may need more concrete examples of how they're not useful.

Also worth noting: there's no Objective-C involved at all on all platforms Swift supports except Darwin.

11 Likes

My understanding is that he's talking about not being able to inherit other protocols in a protocol extension? Perhaps I'm way off, though. Some explicit examples would be great!

1 Like

Does this not work?

protocol P: ObservableObject {}
class ViewModel: P { ... }
1 Like

I think the root of all the issues you're running into is probably that protocols don't conform to themselves. This may seem contradictory (it was certainly confusing to me the first time I heard it), but it's a necessary consequence of the powerful features @David_Smith mentioned in his post.

Over the last few years, the introduction of keywords like any and the design decisions in Apple libraries like SwiftUI have moved in a direction that discourages the use of existentials more and more; there's really no other option but to change the way you think about using them. For example, if you're having a problem like this:

protocol MyServiceProtocol {
     var foo: Int { get set }
}

@StateObject
var model: MyViewModelProtocol // doesn't compile b/c `MyServiceProtocol` doesn't conform to `StateObject`, only concrete types implementing it do

A good option would be to change MyServiceProtocol to this:

class ViewModel: StateObject {
     var foo: Int
}
protocol MyServiceProtocol {
      var model: ViewModel { get set }
}

// keep the reference to the service in DI, and only inject the concrete type into your view:
@StateObject
var model: ViewModel

This lets you give SwiftUI a concrete type like it wants, but the concrete type is trivial, so you can easily create it in tests from your mock, or directly to do snapshot tests of your View if you want. And outside of SwiftUI, you are free to deal in the existential as much as you want.

Here is one example, which can confuse the hell out of programmers coming from C++.

protocol Bar: Hashable {
    var name: String {get}
}

extension Bar {
    func hash (into hasher: inout Hasher) {
        hasher.combine (name)
    }
    
    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.name == rhs.name
    }
}

struct Foo: Bar {
    let name: String
}

struct Jibber: Bar {
    let name: String
}

struct Jabber: Bar {
    let name: String
}

func example () {
    let dv: [any Bar : String] = [
        Foo    (name: "Foo") : "A Foo",
        Jibber (name: "Jibber") : "A Jibber",
        Jabber (name: "Jabber") : "A jabber"
    ]
    // Error: type 'any Bar' cannot conform to 'Hashable'
    // Only concrete types such as structs, enums and classes can conform to protocols
}

func example2 () {
    let dv: [some Bar : String] = [
        Foo    (name: "Foo") : "A Foo",
        Jibber (name: "Jibber") : "A Jibber",
        Jabber (name: "Jabber") : "A jabber"
    ]
    // Error: Conflicting arguments to generic parameter 'τ_0_0' ('Foo' vs. 'Jibber' vs. 'Jabber')
}

func example3 () {
    let dv: [some Bar : String] = [
        Foo  (name: "Foo") : "A Foo",
        Foo  (name: "Foo 2") : "Another Foo",
        Foo  (name: "Foo 3") : "Yet another Foo",
        // Okay
    ]
}

2 Likes

Can you show the C++ equivalent you have in mind? Constructing a map with heterogenous keys that inherit from the same base class is non-trivial because the keys would then be pointers and not values, so their memory would be managed somehow, you have to declare a virtual destructor in the base class, etc. The map would also need a way to specify a custom equality operation to match your example, because operator== on pointers isn’t what you want.

However, I admit that the same example in Java or C# using interfaces is easier to cook up than in Swift. Just not C++ :slight_smile:

3 Likes

It’s somewhat unfortunate that even those protocols that could self-conform (because they don’t have associated types or static members) do not anyway. This is a consequence of the witness table passing implementation though, and other approaches have their own tradeoffs.

I actually think that enums with associated values are often overlooked as an alternative to protocols, in these situations where the lack of self-conformance comes up. Obviously it is a far more limited tool in many ways, but it might be worth thinking about if the set of conforming types is small and fixed and you don’t need each case to be its own type. And exhaustive pattern matching is nice!

4 Likes

I have an struct that does audio processing using a custom codec going in and out of my application. On iOS, it uses AVAudioSession. On MacOS, that doesn’t exist and so I have do some things differently. I have a bunch of os(MacOS) vs os(iOS) code fragments that I’d like to split out completely into two separate struct instances that implement a protocol. There are events around audio starting and stopping, audio levels for metering etc, and so the existing struct is an ObservableObject.

In Java and most other “interface” languages, I could do

structure MacosAudioHandler : AudioHandlerProtocol
structure IosAudioHandler : AudioHandlerProtocol

and all of the “observable” details would be codified into properties with some form of notification for changes etc.

But, in Swift’s design, there are exceptions of usage and conditions of integration that make the whole of the mechanisms between Swift and SwiftUI interactions something that is full of code generation shenanigans instead of clean, clear interfaces that work in all cases.

Yes, I can break some of this up into an observable object that I push values into and make that the struct that is the Observable object. There’s lots of ways to rewrap this. My problem is that all of this disconnection and seemingly incomplete modes of integration make the language constructs seem rather fragile.

You should only have mocks for the parts that have a significant cost and your tests should also have an option to run without mocks.

As for your primary question, the solution is type erasure. If you have associated types, you could also have wrappers that also erase those associated types. (Example from one of my projects: TypedNotificationCenter/Sources/TypedNotificationCenter/Core/TypeErasure at master · Cyberbeni/TypedNotificationCenter · GitHub )

Another solution would be to have a base class and use that instead of the protocol. Sadly the required keyword is only available for initializers, so this is less ergonomic than protocols.

Still a bit unclear what exactly the problem is?


I wonder if protocol is even needed when you can define implementation using closures, like:

struct AudioHandler {
    let handle: (Signal) throws -> ()
}

extension AudioHandler {
   static let iOS: Self { // impl }
   static let macOS: Self { // impl }
   static let test: Self { // impl }
}

Consider making your own AVAudioSession on macOS matching the API that's available on other platforms.

#if os(macOS)
class AVAudioSession {
   //  ...
}
#endif

You'd probably won't need a protocol, although it's hard to say without seeing some examples of what you are struggling with.

You can do this in Swift too; Swift protocols are more capable than Java interfaces.

1 Like

The detail is simply that if types cannot match across all parts of the name spaces and usage, then they are not really types. Java interfaces are first class types. I can use them anywhere I use a class. I can create anonymous classes with them, pass them as types in functions to only expose limited parts of a class etc.

button.addEventListener( new EventListener( )  {
  public void onEvent( EventObject ev ) {
     doSomething();
  }
});

Protocol is an anomaly in the type system. It’s not a type and it’s not usable as a first class type mechanism everywhere. It’s similar in nature to the fact that struct and class work, and we have all kinds of things about those interactions and usability that also create havoc when you find out that not everything that is a “type” can be used in all the other places that types should work the same way.

It just feels really plastic and extremely fragile.

You cannot do this if you need an ObservableObject that matches a Protocol. That’s my problem. I’d really like to have one place that all the dynamics live. I can, in fact, create several small value holders that are ObservableObjects and then have the Protocol expose access to those. In some cases, this might be helpful because I can pass just the "dataThing” around to places that need to use the data. But in the end, these types of disconnects in the Type system of Swift’s Protocol implementation create moments of frustration. I have to rearchitect the objects and their use to make it possible to DI one implementation vs the other, instead of just changing the type from the one class to the type of the protocol, which is what Java allows you to do.

So the exact problem is that

protocol MyViewModel: ObservableObject {}

struct MyView: View {
    @ObservedObject var viewModel: MyViewModel
}

doesn't work, because Swift protocols don't conform to themselves?

You're right that that's the one case where Swift protocols and ObjC protocols/Java interfaces diverge.

The topic of "why don't interface/objc-like conform to themselves" comes up regularly, and I've yet to hear a convincing argument as to why this shouldn't be the case (or at least why one shouldn't be able to opt into that behavior, as Error does), but that doesn't apply in this case — ObservableObject has an associated type (that of the objectWillChange publisher), which means you're already out of that "interface/objc-like protocol" case.

In most cases, you should introduce a generic to deal with this:

protocol MyViewModel: ObservableObject {}

struct MyView<VM: MyViewModel>: View {
    @ObservedObject var viewModel: VM
}

Though I'll certainly agree that there are, in general, situations where this gets tricky.

As someone earlier said though, you only care about substitution per-platform, so a simple

#if os(iOS)
final class MyViewModel: ObservableObject {}
#elseif os(macOS)
final class MyViewModel: ObservableObject {}
#else
#error("unsupported platform")
#endif

Is probably much easier than anything to do with protocols.

If you want to be Java-esque against Swift norms though, you can do what you want with inheritance:

class MyViewModel: ObservableObject {}
#if os(iOS)
final class MyIOSViewModel: MyViewModel {}
#elseif os(macOS)
final class MyMacOSViewModel: MyViewModel {}
#else
#error("unsupported platform")
#endif

struct MyView: View {
    @ObservedObject var viewModel: MyViewModel
}
4 Likes

Regardless of protocols per se, and how they are not exactly types and how are they better or worse compared to interfaces in other languages... I don't quite see you need them here to solve the task at hand... Consider this sketch implementation:

class ViewModel: ObservableObject {
    @Published outputVolume: Float = 0
    init() {
        outputVolume = AVAudioSession.sharedInstance().outputVolume
        AVAudioSession.sharedInstance().observe(\.outputVolume) { _, _ in
            outputVolume = AVAudioSession.sharedInstance().outputVolume
        }
    }
}

and then a simple SwiftUI view visualising the current output volume.

And for macOS - I'd do exactly the same, I just will need to implement a version of class AVAudioSession which be very similar to what iOS has.

#if os(macOS)
class AVAudioSession: NSObject {
    @objc dynamic var outputVolume: Float = 0
    static func sharedInstance() -> AVAudioSession { ... }
    private override init() {
        super.init()
        outputVolume = AudioObjectGetPropertyData(...)
        AudioObjectAddPropertyListenerBlock(...) { _, _ in
            ....
            self.outputVolume = AudioObjectGetPropertyData(...)
        }
    }
}

No protocols needed here!

Could be just an example, but I believe this code been relevant in Java before lambdas were introduced to language as a hack, and since then you can write something like:

button.addEventListener(ev -> doSomething());

which translates to same code in Swift with closures.


And not sure why Swift should be treated as Java, Swift is a different beast and closer to ML languages type system, and yes they're different. It doesn't make anything anomaly, just different purposes, limitations and implementations.


Regarding associated types I believe you still can't do something like interface Interface<?> { void impl(? a); } in Java.

1 Like

Let me try :slight_smile:

...
auto a  = Bar::Allocator ();

auto u  = a.allocate <Foo> ("Foo 1");
auto v  = a.allocate <Jibber> ("Jibber 1");
auto u2 = a.allocate <Foo> ("F 2");
auto v2 = a.allocate <Jibber> ("J 2");

// Use a map with custom ordering
auto isLess = [](const Bar *l, const Bar *r) {
   return l->key () < r->key ();
};

typedef std::map <const Bar*, long, decltype(isLess)> Map;
Map m (isLess);

m.insert (Map::value_type (u, 16));
m.insert (Map::value_type (v, 32));
m.insert (Map::value_type (u2, 64));
m.insert (Map::value_type (v2, 128));

// modify v2
m [v2] = 256;

// Print keys and values
for (auto const &u: m) {
    const auto &v = u.second;
    std::cout << u.first << " --- " << *u.first << ": " << v;
    std::cout << std::endl;
}

// Bars will be released when the Allocator goes out of scope here
Details

Base class and derived classes

// Bar.h

#ifndef __BAR_H
#define __BAR_H

#include <iostream>
#include <sstream>
#include <vector>

namespace {
   struct Bar {
      virtual ~Bar () {
         std::cout << __PRETTY_FUNCTION__
            << std::endl;
      };

      virtual auto name () const -> std::string = 0;

      auto key () const -> std::string {
         auto os = std::ostringstream ();
         os << name().length () << name ();
         return os.str ();
      }

      void print (std::ostream &os) const {
         os << '(' << name () << " key: " << key () << ')';
      }

      void release () const {
         std::cout << __PRETTY_FUNCTION__
            << ": ";
         print (std::cout);
         std::cout << std::endl;
         std::cout << "\t\t\t";
         delete this;
      }

      struct Allocator {
         template <typename T>
         auto allocate (const std::string &name) -> T* {
            auto u = new T (name);
            _store.push_back (u);
            u->print (std::cout);
            std::cout << std::endl;
            return u;
         }

       ~Allocator () {
           std::cout << __PRETTY_FUNCTION__
              << std::endl;
           for (const auto &u : _store) {
               std::cout << '\t' << "release ";
               u->print (std::cout);
               std::cout << std::endl;
               std::cout << '\t' << '\t';
               u->release ();
           }
        }
        private: std::vector <const Bar*> _store;
      };
   };
}

namespace {
   auto operator << (std::ostream &os, const Bar &u) -> std::ostream& {
      u.print (os);
      return os;
   }
}

namespace {
  struct BarLess {
      bool operator()(const Bar *l, const Bar *r) const {
         std::cout << __PRETTY_FUNCTION__ << ": "
             << *l << ' ' << *r
             << std::endl;
         return l->key () < r->key ();
      }
  };
}

namespace {
   struct Foo: Bar {
      ~Foo () {}

      Foo (const std::string &name) : _name (name) {
      }

      virtual auto name () const -> std::string override {
         return _name;
      }
      private: std::string _name;
   };
}

namespace {
   struct Jibber: Bar {
      ~Jibber () {}

      Jibber (const std::string &name) : _name (name) {
      }

      virtual auto name () const -> std::string override {
         return _name;
      }
      private: std::string _name;
   };
}
#endif                                                                                                                                                                  

Driver

// Driver.cc - using a map with custom ordering

#include <map>
#include <iterator>

#include "Bar.h"

static auto test () -> void;

auto main (int, const char *[]) -> int {
   test ();
   return 0;
}

static auto test () -> void {
   auto a  = Bar::Allocator ();
   auto u  = a.allocate <Foo> ("Foo 1");
   auto v  = a.allocate <Jibber> ("Jibber 1");
   auto u2 = a.allocate <Foo> ("F 2");
   auto v2 = a.allocate <Jibber> ("J 2");

// Use a map with custom ordering
#if 1
   auto isLess = [](const Bar *l, const Bar *r) {
      std::cout << __PRETTY_FUNCTION__ << ": "
             << *l << ' ' << *r
             << std::endl;
      return l->key () < r->key ();
   };
   typedef std::map <const Bar*, long, decltype(isLess)> Map;
   Map m (isLess);
#else
   typedef std::map <const Bar*, long, BarLess> Map;
   Map m;
#endif
   
   std::cout << std::endl;
   m.insert (Map::value_type (u, 16));
   m.insert (Map::value_type (v, 32));
   m.insert (Map::value_type (u2, 64));
   m.insert (Map::value_type (v2, 128));
   std::cout << std::endl;
   
   // Print keys and values 
   Map::const_iterator x = m.begin ();
   while (x != m.end ()) {
      const auto &v = x->second; 
      std::cout << x->first << " --- " << *x->first << ": " << v;
      std::cout << std::endl;
      
      ++x;
   }

   // Print keys and values again
   std::cout << std::endl;
   for (auto const &u: m) {
      const auto &v = u.second;
      std::cout << u.first << " --- " << *u.first << ": " << v;
      std::cout << std::endl;
   }

   // Bars will be released when the Allocator goes out of scope here
}