Designing observable objects

102 Views Asked by At

Preface: this is a design question about reactive programming. It's intended to be language agnostic, so it's all psuedo-codey. I suspect whatever the right answer is, would be equally applicable between Rx/ReactiveCocoa/Combine.

I see 3 different ways in which I can design an object to observed. Each one surely has pros/cons, but it's not entirely clear to be what they are.

  1. Your object could have a didChange: Publisher<()> property. When you subscribe, you receive notificaitons about when your pubished object changes, but you don't actually learn anything about the changes, or the new value of the model object. This is simple enough to fix with a map:

    class Point: BindableObject {
        var x, y: Int
        var didChange: Publisher<()> { ... }
    }
    let object: Point = ...
    let streamOfPoints = object.didChange //  Publisher<()>
                                .map { _ in object }  // Publisher<Point>
    

    You simply get the values of the object for yourself, by accessing the object directly by some other reference, at the time you get notified that it changes. If you need to access a stream of x or y values, those are also just one map call away.

    However, this seems like it have some issues.

    1. It's an extra step
    2. It requires you to have access to the original object, so passing the publisher around isn't enough. You have to pass around a (Point, Publisher<Point>) pair, which seems cumbersome.
    3. It might have correctness issues. For example, any delay between the time the didChange event was fired, and you access the object, isn't it possible that you'll read a newer value of the object than that which was present when the change was fired off?

      This approach is my least favorite, but interestingly, this is the approach Apple takes in its Combine framework, with the BindableObject protocol. I suspect it might have to do with some sort of performance gain, from not having to pipe around the object in situations when it might not be wanted. Is that the case?

  2. The most obvious approach is to stream the object itself. var didChange: Publisher<Point> { /* a publisher that emits self over time */ }. This seems to achieve the same as apporach #1, whilst solving the 3 issues I listed. I don't see any value that approach #1 offers over this.

  3. You could make publishers for each of the fields of the object:

    class Point: BindableObject {
        let x = PublishSubject<Int>
        let y = PublishSubject<Int>
    }
    

    This is more granular, so people could subsribe themselves to only those fields which they care about. I don't know how heavy-weight subscriptions are, but there could be some performance wins by subscribing more specifically to only those things which you care about. It's a bit of a contrived example here, because it's hard to think of a case in which it's desireable to know only the x values or y values of a point. But the principle is still generally applicable.

    Accessing xs and ys is also possible using one of the first 2 approaches, by mapping the stream to xs and deduplicating (.map { $0.x }.distinct). But this is calling a lot more mapping closures than a direct subscription like this, which could have performance implications.

    This approach could also be combined with approach 1 or 2, to add a var didChange property of type Publisher<()> or Publisher<Point>, for when you need to observe the entire point object.

    This used to cause a lot of API bloat. In Rx, either:

    1. Either you could have x: Value<Int>, and use x.value all over the place to access the current value
    2. Or, you have var xObservable: Value<Int> and var x: Int { xObservable.value }, but this adds a lot of API bloat.

      Luckily, these two issues are solved by property wrappers, by basically implementing the latter design, but without needing to explictly add all the computed properties (they're generated for you).

Can you please give some guidance on which pattern to use? I suspect that's one of those things that's "obvious with experience", but I'm just not there yet when it comes to reactive programming. Thanks!

1

There are 1 best solutions below

2
Adis On

There's a lot to take in this question, but I would probably say to go with the option 2.

I'm not much of an expert with designing reactive frameworks, but looking at your options, we can pretty much eliminate the first one as being too limited to use, which leaves options 2 and 3.

Option 3 allows a lot of granularity as you mention, but in my opinion, this kind of granularity causes a lot more bloat for the regular use cases - e.g. I'm interested in the entire model that's changed, and with .map and .filter I can easily reduce the stream in the option 2 back to what would be the granular option when needed easily. It seems to me that more often the case is to look at the entire model that's being observed, rather than a part of it.

Currently, it also seems that the option 2 is simply prevalent in the design of Rx and similar frameworks, and I would argue that's simply the experienced way to go about it.

Hope this helps, seems like this is more suited for a discussion board rather than a simple answer.