What's up with the Combine framework?

From my PoV it is very simple, I guess neither Apple or other companies want to spend time to cover not their platforms. At the same time if somebody want to use some open sourced reactive framework then he/she already has plenty of choices (Rx, ReactiveCocoa, etc).

I understand that DisposeBag is a convenience tool for working with observables. What I'm speaking about is that the amount of bookkeeping in RxSwift in general (DisposeBag being a best case) is high when compared to other methods. It feels like going back to manual memory management.

The backlash if they had done significant engineering and API change on Rx would have dwarfed the complaints about Combine.

6 Likes

huh?

Binary size is a concern but not the main. The functionality is to big for chaining simple tasks. DispatchGroup does a much more lightweight approach for small tasks. I always get stuck after some days of trying RxSwift. I think Combine has the same problems but maybe not the stack trace problem. Have to experiment.

This is a bit of a trap, though. Dispatch is fine for the simple stuff, until your requirements grow (which they always seem to) to a point where you find yourself painted into a corner called callback hell.

What is it that typically gets you stuck?

This is not what I mean. Callback can get a mess but RxSwift in every project I use is worse because of the stack trace for one and second because of the naming convention.

Lets first clarify the code currently needed to do 3 parallel request and know one response


let dispatchGroup = DispatchGroup()

// Every object below has its own Queue and a different Model for its result

let request1 = Request(dispatchGroup)
let request2 = Request(dispatchGroup)
let request3 = Request(dispatchGroup)

var result1: Type1?
var result2: Type2?
var result3: Type3?

request1.attempt { result in result1 = result /*...*/}
request2.attempt { result in result2 = result /*...*/}
request3.attempt { result in result3 = result /*...*/}

// The code below will only get called when all 3 are finished
dispatchGroup.notifyMain { 
// or something else. Point is this is type safe, all results have been set and on an error I can handle that in the individual handles above
print(result1 + result2 + result3) 
exit(EXIT_SUCCESS)
 }

dispatchMain()

So to your second question this is where I always get stuck. I try to write this code that actually works with RxSwift. The problems I experience are

  1. I never know how to start a request. I have to read a few minutes and then I now a request is called something more abstract its an Observable.
  2. Then I create 3 observables for my request. Every observable has a different data type as a response.
  3. But then I still need to start the request. Above this is calling attempt on the request. But for RxSwift I need to create a publisher I think.
  4. So I have the publisher and then I need to combine the result of all 3 in one. I have combineLatest or anything else but then the types gets messed up.
  5. So I have to map inbetween to something common so I can then print it together
  6. Then I worry about memory management an try to figure out where to put the dispose bag.

I get stuck in figuring out why my code using DispatchGroup is not simple enough. Because my code is

  • type safe
  • has a readable stack trace while debugging
  • memory is managed by swift

I would really appreciate any opinion on this because I would like to know but I do not see it. Thanks

2 Likes

Hello. Without seeing an example of your Rx code I canā€™t gauge how far along you are with it, so if this is either condescending or too complicated I apologise. Iā€™d be happy to explain further or chat about Rx/Combine.

This is an example of how I would tackle your problem using FRP, in this case RxSwift.

typealias CombinedResponses = (Result<String, RequestFailure>, Result<Bool, RequestFailure>, Result<Int, RequestFailure>)

func exampleWithTrigger() -> Observable<CombinedResponses> {
    let requestTrigger: Observable<Void> = .just(())

    let request1: () -> Observable<Result<String, RequestFailure>> = { .just(.success("1")) }
    let request2: () -> Observable<Result<Bool, RequestFailure>> = { .just(.success(false)) }
    let request3: () -> Observable<Result<Int, RequestFailure>> = { .just(.success(1)) }

    return requestTrigger
        .flatMap { _ in
            return Observable.zip(request1(), request2(), request3())
        }
}

func exampleWithoutTrigger() -> Observable<CombinedResponses> {
    let request1: () -> Observable<Result<String, RequestFailure>> = { .just(.success("1")) }
    let request2: () -> Observable<Result<Bool, RequestFailure>> = { .just(.success(false)) }
    let request3: () -> Observable<Result<Int, RequestFailure>> = { .just(.success(1)) }

    return Observable.zip(request1(),
                          request2(),
                          request3())
}

You can subscribe to the results of either of these functions to kick off the three network requests and have access to all three responses.

Your points about the stack trace are valid, but I've found the debugging tools included with RxSwift have helped me solve all my issues up until now without having to rely on the stacktrace. In particular the .debug() method.

When you say you want your memory to be managed by Swift, using DisposeBags does not mean we're manually managing memory. When we subscribe to an Observable in in RxSwift the return type of the Subscription is Disposable which exposes one method, dispose(). This allows us to do things like cancel in flight network requests when we're no longer interested in an Observable.

Dispose bags give us a convenient way to store Disposables, and when the Dispose bag is itself deallocated it calls dispose() on each Disposable in its deinit.

These examples are fairly trivial, so if it would help I'm more than happy to flesh them out a bit.

3 Likes

Hey doozMen, I appreciate your time for writing that up.

There are fair criticisms in there, but they're too heavily mixed in with a familiarity for one framework (Dispatch), and not the other, making it a completely unfair comparison.

Further, this example is rather cherry-picked, choosing a case that Dispatch is ideal for. If you add any real world complexities (read: necessities), you'll find it getting MUCH harder using Dispatch. Some examples include:

  1. Retry logic, with back off (exponential + random jitter)
  2. Throttling API requests
  3. Actually using the resulting value. Because this is async code, you can't just return it (well, you can, with Rx ;) ), so you're forced to use some other scheme. In this example, print is acting like a completion handler, which is fine for a demo, but horrible for real life projects. You just end up closure parameters for every async API, and you find yourself in the pits of call back hell (farewell nice stack traces)

point-by-point response:

  1. It's not obvious, and I think the Rx docs are lacking, but this point is more of a testament to your familiarity with Dispatch, and non-familiarity with Rx.

  2. Didn't you imply at the end of your post that Rx isn't type safe? :face_with_monocle:

  3. Nope, just a triggering observable, see mikevelu's example

  4. What do you mean by "types gets messed up"?

  5. This would be an issue in a language like Java, where you'd be forced to write a class (with constructor, fields, getters/setters), but in languages with anonymous types (like tuples in Swift), this is no problem at all. If anything, it's a benefit, you have one programmatic entity (one tuple value) for one conceptual entity (one aggregated async computation result).

  6. It depends on the usage context, but possibly yes. I think this is one area where Rx docs are lacking, because people have this notion that dispose bags are some kind of complicated voodoo, when they're really just an array of subscriptions, held by the dispose bag on your behalf to keep them alive.

...

  • Could you elaborate on this? What part of Rx isn't type safe?

  • In this trivial example, yes. But I think that's an unfair comparison. Put this in the context of the callback hell you would find it in, and then compare.

  • Is this implying that Dispose bags aren't Swift code? I'm not exactly sure of the argument here. I don't see the difference (in terms of inconvenience) between a dispatch group and a dispose bag. They have almost the same syntax (one initialization, followed by one call per thing that's involved in the computation)

1 Like

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?

13 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.

1 Like

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: GitHub - OpenCombine/OpenCombine: Open source implementation of Apple's Combine framework for processing values over time.

16 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.

4 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.

5 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.

6 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.