What's up with the Combine framework?

So, honestly, I have a problem with the Combine framework. To quote Wikipedia (emphasis added):

In computing, reactive programming is a declarative programming paradigm concerned with data streams and the propagation of change.

Reactive streams are a programming paradigm; it's a real stretch to consider them part of the OS. There is a big difference between Combine and OS libraries such as SwiftUI or CryptoSwift. Many other languages are incorporating streams/observables in their standard/core libraries (e.g. Java 9, ECMAScript, .Net).

To put it another way: if the community proposed their own reactive stream design for the standard library, I think it would be met with broad support. The primary reason nobody has done so is due to lack of a real plan for concurrency.

If Apple is adopting the reactive paradigm, it means that lots of Swift code will be using it. If they don't want to upstream Combine, I think it would be worth exploring our own design for reactive streams in the standard library.

Another thing to consider is: what would happen if we did propose adding reactive streams to the standard library? Would Apple oppose it because Combine also exists?

12 Likes

Isn't it exactly the "make our own" attitude that is the reason Apple products are so eminently usable?

I love RxSwift but they had to write everything on top of the public face of Swift, with all that entails for performance and debugging. Apple are said to have been working on Combine since before Swift, and they can use and make internal APIs that makes Combine significantly faster than what RxSwift could ever achieve.

InMost of all, I have a hard time seeing Apple building crucial, not to say transformative, internal frameworks on an external open source project maintained by a very small group of people, no matter how dedicated they have been.

Something that would have made more sense would be to recruit the maintainers, but I'm not sure that would be appreciated either.

But why shouldn't Apple make their own? They can do it much better than anyone else.

It's interesting if the community could create an open source version with the same APIs as Combine Framework that would be available on lower iOS versions and other platforms that don't have support for it.

3 Likes

I’m actually working on this thing (mostly for educational purposes). It’s very much work in progress, and you can do little with it, but the groundwork is laid. I’ve also set up the cross-platform testing and benchmarking infrastructure.

Feel free to try it: https://github.com/broadwaylamb/OpenCombine

14 Likes

mikevelu and @AlexanderM thanks for the informative responses. Caught up in work this week. Will come back to this interesting topic this weekend. Have a good week.

1 Like

Apparently they have been working on Combine for 6 years and it is said to be built on lower level stuff than Foundation. I'm not sure why you are so unhappy with Combine that you want to build your own alternative, but given the enormous effort Apple has put into it, and their unmatched access to the language internals, whatever the community builds will be vastly inferior.

1 Like

In my opinion, the GCD example you'e responding to is much more readable than this reactive implementation. The Rx example has much more boilerplate, and relies on an understanding of the implicit behavior of observables, which is not at all obvious. I'm not sure why I would want to give up readable stack-traces and simple, imperative code for... which benefits exactly?

With all due respect, I don't understand how this could be considered preferable in any way.

2 Likes

My issue with DisposeBags is not that they are "complicated voodoo", but that subscription management is a layer of manual book-keeping which is required in Rx, but not in other paradigms (as demonstrated by @doozMen's example). It feels like a step backwards: like going back to manual memory management after GC/ARC.

With all due respect, my experience with Rx in large projects is that it doesn't save you from any sort of "hell" - you still end up with lots of closures and indirection. The main difference is that with Rx your stack-trace isn't helpful, and you are working at higher levels of abstraction, so it's less easy to reason about your code. Instead of problems being the result of my own faulty logic, they emerge from something like an incorrect assumption about how a reactive chain is executing.

You could say that "good Rx code" doesn't have these issues, but the same could be said for well written callback-driven code. It's not at all clear to me what concrete benefits are provided by Rx which justify the downsides.

3 Likes

Hey @Spencer_Kohan! Thanks for taking the time to reply, I'll do my best to respond to your points. Like I said to @doozMen I'm not sure how familiar you are with Rx, so if any of this comes across as condescending please accept my apologies up front. I'm interested in the polarising opinions on Rx so I'd be more than happy to discuss this further with you, I've had a fairly positive experience with RxSwift over the last 2 years but I'm well aware of the challenges involved in learning/writing idiomatic Rx code and applying the paradigm.

The GCD example will be more “readable” than the Rx example to the majority of iOS developers as they will usually have an understanding of GCD & Dispatch Groups. They’ll be able to sift through the noise and immediately see the intent of the code. Someone with an understanding of Rx would have the same reaction when reading the Rx example.

I’m not sure I agree here, and I don’t want to get into counting lines of code, but the Rx example has less code and less boilerplate. There is of course the additional mental load of figuring out what all the high-level declarative APIs do, but learning a new API isn’t a problem unique to Rx.

In my example I create 3 requests, similar to what happens in the GCD example, and then I perform an operation on them. I can’t agree that there is much more boilerplate. The trigger example could maybe be considered as boilerplate, but like I said in my reply it's a very trivial example. Things like triggers are usually passed in as arguments which you observe and act upon events.

Could you not say the same thing about the GCD example? Just looking at the declaration of DispatchGroup doesn’t tell you much about its implicit behaviour. I’d expect myself and other developers to read documentation of any new APIs we use and understand how they work. The same thing applies to Rx.

Firstly to address the issue with stack traces, it’s a concern that I share. There are mitigations like Debug Mode but unfortunately the stack traces are in general a lot bigger in Rx traces than in non Rx traces. Like all things in technology just pick the right tool for the job. If the issue with stack traces is a deal breaker then Rx just might not be the right tool for the job at hand. I will say that after having shipped new apps written using Rx, and supporting existing apps using Rx, I haven’t been stung by the large stack traces in Rx.

Your comment about simple, imperative code - imperative code isn’t simple by default, you can write complex code regardless of the paradigm you choose. You aren't giving up writing simple code just by using Rx.

Just looking at this code snippet isn’t enough to judge whether Rx is preferable or not. Rx is just another tool which solves a particular set of problems. If you have a problem well suited to Rx, I’d highly recommend investigating it as a potential use case. I’ve personally had a very positive experience with it so far.

2 Likes

@mikevelu I appreciate your response. I actually have worked quite extensively with RxSwift. I have used it on several large-scale projects over the past couple years in production.

I approached it with a very open mind and put in time to solve many complex problems using the paradigm, however in my experience while it does have some niche use-cases where it may provide value, in the vase majority of cases it has at best displaced issues, and at worst has added complication to projects it has been a part of.

I've also recently removed Rx completely from a project where it was heavily used, and as a result our codebase has gotten significantly smaller, compile time and performance have improved, and our velocity has increased with respect to bug fixes and new features.

While I agree that things you already understand will always appear more intuitive, I do think there's a meaningful difference between the dispatch example and the Rx example given.

In the dispatch example, an inexperienced programmer might have to look up what dispatchGroup.notifyMain does, but aside from that it is fairly easy to understand the flow of control, and anyone familiar with trailing closures should be able to read this code quite easily from top to bottom.

In the Rx example however, if you have not spent at least some time with Rx, I do not think it is intuitive, for example, to understand why your requests are triggered by flat-mapping a void observable. You first have to understand what an observable is, how event streams work in Rx, what a just operator is and so on. Not only that, but the Observable abstraction is a poor fit for purpose here, since these requests are finite processes which will emit exactly one value. Wrapping them in an abstraction which implies it may emit 0-n values makes the intent less clear to another developer reading this code.

I do agree that any tool can be used poorly, and it's possible to overcomplicate solutions regardless of the coding style. I would say though that not all tools are created equal. Just for the baseline usage of RxSwift, you are already adding a dependency of thousands of lines of code to your project, and you are surrendering the basic flow-of-control of your program to the scheduler.

I would be genuinely interested to hear for which problems Rx is uniquely suited. In general when I see the benefits of Rx discussed, the advantages claimed tend to be fairly high-level and abstract. I would be happy to see a concise, concrete example where Rx provides clear value over comparable approaches.

4 Likes

Dispatch queues and groups are exactly the same style of "manual book-keeping", so I don't see why you so readily discount them while you take issue with dispose bags.

It’s worth pointing out that reactive programming does not inherently necessitate manual bookkeeping. Unfortunately this is how all of the popular libraries are designed and Combine copied that design.

For a count-example, see the talk Do It Yourself FRP. The design used here is to provide a context object that specifies an upper bound for a subscription’s lifetime at the point of subscription. The context is also captured weak. When an event is arrives it is upgraded to a strong reference that is provided to the observing callback (a sink in Combine terminology).

It is possible to go even further than this talk does, having the context object specify a scheduler used to invoke the callback using a ScheduledObject protocol. This approach is inspired but the actor model where objects are associated with a concurrency context. It avoids the potential (and relatively common) mistake of forgetting to use receive(on:) to move to the correct queue.

But what if you want to terminate a subscription before the primary context object is deinitialized? There are a variety of solutions to this. One is to use subcontext keypaths. This approach retains the benefits of providing the primary context to the callback and automatically managing scheduling while allowing the subcontext to be released before the primary context. In this approach the primary context continues to provide an upper bound on the lifetime of the subscription.

Another approach to decoupling subscription lifetime from a context object is cancellation tokens. This approach is very flexible, allowing the subscription lifetime to be bounded in arbitrary ways, while still requiring subscribers to specify a bound of some kind.

Designs that require subscriptions to have a bounded lifetime are really important IMO. It’s relatively easy to accidentally lead subscriptions with the Disposable / Cancellable pattern. Depending on the subscription these leaks can be relatively inconsequential or they can be serious bugs.

In the library I work on, some subscriptions are backed by an event stream coming from the cloud. The stream is filtered on the cloud based on active subscriptions. Some subscription configurations result can result in a lot of data coming across the network. This is absolutely necessary to support some of our use cases, but it is totally unacceptable for these subscriptions to leak. The Disposable / Cancellable pattern is unnecessarily fragile and unworkable in this context.

So I agree that subscription management in Combine is suboptimal and should not require manual bookkeeping to get right. But I think it’s important to distinguish this flaw from reactive libraries. It’s probably too late to change now, but it would have been possible for Combine to go in a different direction. (I would love to be wrong about it being too late to change)

5 Likes

Not really. Dispatch groups are usually wholly contained within the scope of the one method where they're used. With dispose bags I end up with an extra bookkeeping member variable on all my types which use observables.

Also dispatch groups are only needed for more complex, branching cases with Dispatch. With observables I always have to worry about cleanup.

Reactive is perfect if you deal with unsolicited events. But if you trigger an asynchronous request, Promises etc. are often the better and simpler choice to process a response.
I really have hoped, that async/await makes it to the language first to build actors and reactive on top of that.

4 Likes

I agree with those expressing concern about the complexity of Rx frameworks, including Combine. 90% of the time you don't need 90% of their features, leading extreme complexity for simple scenarios. With Combine being integrated into the OS, perhaps there are opportunities for optimization that other libraries don't have. But really I was hoping that Apple would have come up with more clever, high level APIs to minimize the complexity developers see. But perhaps the community will build it, and things like @Published and the other property wrappers will help. I think judicious use of Combine will help manage the complexity of apps using it while still getting some of the benefits.

3 Likes

Combine is a low-level framework. If you did try SwiftUI for instance, there is zero complexity to use the system that is based on Combine. And I guess this will be the case for others high-levels API based on Combine.

2 Likes

As I noted, reactive programming is a popular, modern programming paradigm which many languages bigger than Swift are incorporating in their standard libraries. It would be reasonable to suggest that concepts such as publishers and subscribers should be incorporated in to Swift's standard library, too.

Nothing prohibits Apple optimising the implementation on their own platform. The Swift standard library is also shipped with Apple's OSes and their version of it can benefit from private OS APIs and other optimisations.

Unfortunately, it seems to me that Combine shuts the door to reactive streams in the Swift standard library, at least until Apple are ready/willing to change their current stance (if they ever are).

2 Likes

Are any of the SwiftUI features actually based on Combine? The two bridge pretty seamlessly, but I'm not sure SwiftUI is implemented on top of Combine.

I believe @State is implemented using Combine, and that seems rather necessary for making a SwiftUI interface actually respond to user input.

Binding which is use everywhere in SwiftUI is implemented over Combine.
State change tracking may be too, but it is not exposed in the API, so this would be an implementation detail.

Many piece of SwiftUI are Subscribers, and the user is just supposed to bring a Producer. SwiftUI hides most of the complexity and let the user do the easy job.

2 Likes
Terms of Service

Privacy Policy

Cookie Policy