00:06 In this episode, we're going to build upon the Future
type we
implemented in a previous
episode. Specifically, we'll
use the Future
type as a basis to build a simple reactive implementation.
While a future is an abstraction around a one-off callback, a signal in a
reactive library models values over time.
00:38 You'd use a future in an asynchronous API if the call only delivers
one result, e.g. most network calls or some expensive computation. In contrast,
you'd use a signal if you have to handle multiple values over time, like for a
button that can be tapped multiple times, or for a text field that changes its
value with each new user input. In the case of the signal, we have to think
about some additional issues, like memory management (to avoid reference
cycles), and subscription management (to allow clients to opt in and opt out of
receiving values of a particular signal).
01:17 The motivation behind writing a small reactive library from scratch
is really to understand the concept of reactive programming on a deeper level.
Our goal isn't to write production-ready code, but rather to wrap our heads
around what's going on behind the scenes when using one of the existing reactive
libraries.
Turning Future into Signal
01:53 We start out with the Future
type from episode
#36:
final class Future<A> {
var callbacks: [(Result<A>) -> ()] = []
var cached: Result<A>?
init(compute: (@escaping (Result<A>) -> ()) -> ()) {
compute(self.send)
}
private func send(_ value: Result<A>) {
assert(cached == nil)
cached = value
for callback in callbacks {
callback(value)
}
callbacks = []
}
func onResult(callback: @escaping (Result<A>) -> ()) {
if let value = cached {
callback(value)
} else {
callbacks.append(callback)
}
}
}
01:55 As a first step, we remove the caching of the result, because this
doesn't make sense for a signal anymore, at least not in this form. We also
remove the initializer, rename the type from Future
to Signal
, make send
public for now, and rename onResult
to subscribe
. Lastly, we don't clear out
the callbacks at the end of the send
method, since we now want to be able to
send multiple values. This simplifies the code quite a bit:
final class Signal<A> {
var callbacks: [(Result<A>) -> ()] = []
func send(_ value: Result<A>) {
for callback in callbacks {
callback(value)
}
}
func subscribe(callback: @escaping (Result<A>) -> ()) {
callbacks.append(callback)
}
}
03:31 Now we can try out the Signal
:
let signal = Signal<String>()
signal.subscribe { result in
print(result)
}
signal.send(.success("Hello World"))
04:30 The Signal
implementation is now very simple, but also very
limited. We'll have to add back some more complexity once we build out this type
further.
Separating Send/Subscribe APIs
04:49 The first enhancement we'll make is to separate the Signal
's API
into a sending part and a receiving part. This is a very common pattern in
reactive libraries, because it allows you to control who can send new values
into the signal vs. who's only allowed to subscribe.
05:22 For this we add a static method, pipe
, to Signal
, which
returns those two things: a function with which we can send new values, and the
signal itself, which is read-only. You can take the name of the method quite
literally: the returned tuple can be thought of as a physical pipe with two
ends. At one end you can insert new values (also called the "sink"), and at the
other end you can observe what's coming out:
final class Signal<A> {
static func pipe() -> ((Result<A>) -> (), Signal<A>) {
let signal = Signal<A>()
return (signal.send, signal)
}
private func send(_ value: Result<A>) {
}
}
06:43 With the pipe
method in place, we were able to make send
private again. The ability to send new values now only gets exposed via the
pipe
method.
07:06 To try out these changes, we just have to modify our test code a
little bit:
let (sink, signal) = Signal<String>.pipe()
signal.subscribe { result in
print(result)
}
sink(.success("Hello World"))
Adding a Signal to a Text Field
07:30 Let's put all of this code into a more realistic context. For this
purpose, we're going to fake a view controller in our playground and subscribe
to a text field's signal in viewDidLoad
:
class VC {
let textField = NSTextField()
var stringSignal: Signal<String>?
func viewDidLoad() {
stringSignal = textField.signal()
stringSignal?.subscribe {
print($0)
}
}
}
var vc: VC? = VC()
vc?.viewDidLoad()
vc?.textField.stringValue = "17"
09:05 To make this code work, we have to add the signal
method in an
extension to NSTextField
. In this method, we can take advantage of the
send/subscribe separation we've created above — the text field is able to send
new values to the signal, while any other code can only observe:
extension NSTextField {
func signal() -> Signal<String> {
let (sink, result) = Signal<String>.pipe()
KeyValueObserver(object: self, keyPath: #keyPath(stringValue)) { str in
sink(.success(str))
}
return result
}
}
10:23 In this approach, we don't want the text field to reference the
signal, but rather the signal should reference the text field (via the key-value
observer). This has the advantage that it's more flexible, since we'd have to
subclass the text field or use associated objects to store a reference to the
signal in the text field itself.
12:09 The KeyValueObserver
class is a simple wrapper around KVO that
calls a function each time the observed property changes. It also manages the
observation lifetime: once the key-value observer instance goes away, it stops
observing the text field. As long as the key-value observer is alive, the
observed object is guaranteed to be around as well, since the observer instance
holds a strong reference to the observed object.
12:39 Since we're not yet holding on to the KeyValueObserver
instance,
it gets deinited immediately and observation stops. The easiest way to fix this
is to just store the observer in a property of the signal. We'll call this
property objects
for now:
final class Signal<A> {
var objects: [Any] = []
}
extension NSTextField {
func signal() -> Signal<String> {
let (sink, result) = Signal<String>.pipe()
let observer = KeyValueObserver(object: self, keyPath: #keyPath(stringValue)) { str in
sink(.success(str))
}
result.objects.append(observer)
return result
}
}
15:16 Now the observer will be alive as long as the signal is alive.
Since the signal is stored in a property of the view controller, new values will
be sent as long as the view controller is around.
Dealing with Reference Cycles
15:33 Unfortunately, we accidentally introduced a reference cycle, which
makes it so that the signal will never be deallocated.
15:46 Reference cycles are difficult to debug sometimes, since many
objects can be involved in creating the cycle. A very simple technique to help
diagnosing these cycles is to add a print statement to a class' deinit
. For
example, we can try this with our view controller class:
class VC {
deinit {
print("Removing vc")
}
}
var vc: VC? = VC()
vc?.viewDidLoad()
vc?.textField.stringValue = "17"
vc = nil
Once we set the vc
variable to nil
, the view controller gets deallocated and
"Removing vc"
is printed in the console.
16:33 However, when we add the same debug code to the Signal
type, it
doesn't get printed in the console. This clearly shows that the signal instance
never gets deallocated:
final class Signal<A> {
deinit {
print("deiniting signal")
}
}
Xcode's Memory Debugger
16:57 One very useful tool to further diagnose the problem is Xcode's
memory debugger. Since this doesn't work in a playground, we'll just copy/paste
our code into a command line project and continue working in there.
17:21 Now we set a break point after we've set the vc
variable to
nil
and run the project. Once the debugger stops on this line, we can open the
memory debugger:
17:40 In the sidebar we see all the objects that are alive, e.g. the
Signal<String>
instance that shouldn't be around anymore. On the right-hand
side, we see all the objects involved in the reference cycle that keeps the
signal alive: the signal references the key-value observer through its objects
property, and the key-value observer references the signal via a few more
intermediate steps.
18:16 This visualization can be very helpful in diagnosing the problem.
However, sometimes the memory debugger isn't able to generate a graph like this,
but it'll still show the objects that are alive in the sidebar.
Sketching Out Reference Cycles
19:04 Another helpful approach is to sketch out the references of all
the objects on a piece of paper. We do this a lot to understand how we've
created a reference cycle. In the example at hand, the diagram looks like this:
19:10 In the upper-left corner we have the view controller, which
strongly references the text field and the string signal (as indicated by the
arrows). The string signal references the closure with the print statement, as
well as the key-value observer it uses to observe the text field for changes.
The key-value observer in turn holds a strong reference to the text field.
19:40 This might already look like a reference cycle when you look at
the left part of the diagram (view controller – string signal – key-value
observer – text field – view controller). However, not all references are
pointing in the same direction, so we don't have a problem yet.
19:55 The key-value observer also references the closure that gets
called when the observed property changes. In our case, this is the function
that feeds the new values into the sink. In turn, the sink strongly references
the signal, since the sink is just the signal's send
method. So here's our
reference cycle.
Fixing the Reference Cycle
20:14 To fix the cycle, we have to think about which of the references
could be a weak reference. For example, we can't make the reference from the
signal to the observer weak, since the observer wouldn't be referenced strongly
by anything anymore. The same goes for the closure that's referenced by the
observer. Therefore, we're going to break the cycle by making the reference from
the closure to the signal weak:
20:40 For this, we have to modify our implementation of the Signal
's
pipe
method. Instead of returning signal.send
, we're going to wrap this in a
closure that captures signal
weakly:
static func pipe() -> ((Result<A>) -> (), Signal<A>) {
let signal = Signal<A>()
return ({ [weak signal] value in signal?.send(value) }, signal)
}
21:50 With this minor change, the reference cycle is fixed, which we
can see from the printout in the console "Removing signal"
, as well as in the
memory debugger.
22:31 In this case, the reference cycle was caused by a mistake in the
implementation of Signal
. However, there are also cases where it's the
responsibility of the consumer of the API to not create cycles. For example, if
we were to refer to self
in the closure we pass to the subscribe
call in
viewDidLoad
, we would've created a reference cycle between the view controller
and the signal. To avoid that, we'd have to specify weak self
or unowned self
in the closure's capture list.
23:27 With this problem out of the way, we'll add a bit more
functionality to our Signal
type next time!