ReactifSwift: async function composition

Posted on Oct 15, 2019

Async operators debounce, throttle or delay that functional reactive programming libraries as RxSwift and ReactiveSwift provide are super useful and expressive. Though their biggest benefit lays within composability.

This playground tries to achieve the same using plain functions with little help of a global scheduler for a greater testing experience. For better composability it relies on Overture in version 0.2.0.

Originally written at 2018-06-10

Example


var count = 0
let countChars = with({ count += $0 }, pipe(
    map(get(\String.count)),
    throttle(time: 100)
))

let time = TestingScheduler()
ReactifCurrent.scheduler = time
countChars("A")
time.tick(50)
countChars("B")
time.tick(100)
countChars("Abc")
count // 4

Implementation

// import Overture
import Foundation

typealias Unary<A> = (A) -> Void

protocol Invalidatable {
    func invalidate()
}
extension Timer: Invalidatable {}

protocol Scheduler {
    func now() -> Date
    func delay(_ time: TimeInterval, _ f: @escaping () -> Void) -> Invalidatable
}

final class TestingScheduler: Scheduler {
    private var delayed: [FakeTimer] = []
    private var currentInterval: TimeInterval = 0

    init() {}

    private final class FakeTimer: Invalidatable {
        let date: Date
        var fire: (() -> Void)?

        init(date: Date, fire: @escaping () -> Void) {
            self.fire = fire
            self.date = date
        }

        var isValid: Bool {
            return fire != nil
        }

        func tick(_ now: Date) {
            if let fire = fire, now >= self.date {
                fire()
            }
        }

        func invalidate() {
            self.fire = nil
        }
    }

    func tick(_ interval: TimeInterval) {
        self.currentInterval += interval
        let currently = now()
        self.delayed.forEach { $0.tick(currently) }
        self.delayed = self.delayed.filter { $0.isValid }
    }

    func now() -> Date {
        return Date(timeIntervalSince1970: currentInterval)
    }

    func delay(_ interval: TimeInterval, _ f: @escaping () -> Void) -> Invalidatable {
        let timer = FakeTimer(date: now().addingTimeInterval(interval), fire: f)
        delayed.append(timer)
        return timer
    }
}

final class TimeScheduler: Scheduler {
    init() {}

    func now() -> Date {
        return Date()
    }

    func delay(_ interval: TimeInterval, _ f: @escaping () -> Void) -> Invalidatable {
        return Timer(timeInterval: interval, repeats: false) { _ in
            f()
        }
    }
}

struct ReactifRuntimeContext {
    var scheduler: Scheduler = TimeScheduler()
}

var ReactifCurrent = ReactifRuntimeContext()

struct Sink {
    typealias Completion = () -> Void
    let call: (@escaping (@escaping Completion) -> Void) -> Bool
    init(_ call: @escaping (@escaping (@escaping Completion) -> Void) -> Bool) {
        self.call = call
    }
}

extension Sink {
    static func lock(name: String? = nil) -> Sink {
        let lock = NSLock()
        lock.name = name
        return Sink { f in
            if lock.try() {
                f(lock.unlock)
                return true
            } else {
                return false
            }
        }
    }

    static func recursiveLock(name: String? = nil) -> Sink {
        let lock = NSRecursiveLock()
        lock.name = name
        return Sink { f in
            if lock.try() {
                f(lock.unlock)
                return true
            } else {
                return false
            }
        }
    }

    static func synchronous() -> Sink {
        return Sink { f in
            f({})
            return true
        }
    }
}

func filter(_ includes: @escaping () -> Bool) -> (Sink) -> Sink {
    return { (sink: Sink) in
        Sink { (f: @escaping Unary<Sink.Completion>) in
            includes() && sink.call(f)
        }
    }
}

func reschedule(_ transform: @escaping (@escaping Unary<Sink.Completion>, @escaping Sink.Completion) -> Void) -> (Sink) -> Sink {
    return { (sink: Sink) in
        Sink { (f: @escaping Unary<Sink.Completion>) in
            sink.call { complete in
                transform(f, complete)
            }
        }
    }
}

func delay(time: TimeInterval) -> (Sink) -> Sink {
    return reschedule { f, completion in
        ReactifCurrent.scheduler.delay(time) {
            f(completion)
        }
    }
}

func extend(time: TimeInterval) -> (Sink) -> Sink {
    return reschedule { f, completion in
        f {
            ReactifCurrent.scheduler.delay(time, completion)
        }
    }
}

func debounce(time: TimeInterval) -> (Sink) -> Sink {
    var currentAttempt: Invalidatable?
    return reschedule { f, completion in
        currentAttempt?.invalidate()
        currentAttempt = ReactifCurrent.scheduler.delay(time) {
            f(completion)
        }
    }
}

func throttle(time: TimeInterval) -> (Sink) -> Sink {
    var lastInvocation: Date?
    return filter {
        let now = ReactifCurrent.scheduler.now()
        defer { lastInvocation = now }
        if let lastInvocation = lastInvocation, now.timeIntervalSince(lastInvocation) < time {
            return false
        } else {
            return true
        }
    }
}

func schedule<A>(_ factory: @escaping @autoclosure () -> Sink) -> (@escaping Unary<A>) -> Unary<A> {
    return { f in
        let sink = factory()
        return { a in sink.call { f(a);$0() } }
    }
}

// TODO: throttle using time after completion instead of starting time
func throttle<A>(time: TimeInterval) -> (@escaping Unary<A>) -> Unary<A> {
    return schedule(with(.synchronous(), throttle(time: time)))
}

func exhaustA<A>(ending time: TimeInterval? = nil) -> (@escaping Unary<A>) -> Unary<A> {
    return schedule(with(.recursiveLock(), extend(time: 10)))
}
func exhaust<A>(ending time: TimeInterval? = nil) -> (@escaping Unary<A>) -> Unary<A> {
    return { f in
        var lock = NSLock()
        return { a in
            if lock.try() {
                f(a)
                if let time = time {
                    ReactifCurrent.scheduler.delay(time, lock.unlock)
                } else {
                    lock.unlock()
                }
            }
        }
    }
}

func map<A, B>(_ transform: @escaping (A) -> B) -> (@escaping Unary<B>) -> Unary<A> {
    return { f in pipe(transform, f) }
}

func filter<A>(_ include: @escaping (A) -> Bool) -> (@escaping Unary<A>) -> Unary<A> {
    return { f in
        return { a in
            if include(a) {
                f(a)
            }
        }
    }
}

func debounce<A>(time: TimeInterval) -> (@escaping Unary<A>) -> Unary<A> {
    return { f in
        var currentAttempt: Invalidatable?
        return { a in
            currentAttempt?.invalidate()
            currentAttempt = ReactifCurrent.scheduler.delay(time) {
                f(a)
            }
        }
    }
}

Conclusion

I think this is a quite cool idea for 240 lines of code. If just a few functions are required and using a FRP library would be too much just for a few functions, this might be a lightweight, but valuable alternative.

Though as we now have Combine by Apple, this is more likely to be a niche idea.

download this playground