[swift-evolution] Pitch: Progress Tracking in Swift

Dave Abrahams dabrahams at apple.com
Wed Jan 20 12:42:16 CST 2016


on Sun Jan 17 2016, Charles Srstka via swift-evolution <swift-evolution-m3FHrko0VLzYtjvyW6yDsg-AT-public.gmane.org> wrote:

> Introduction:
>
> This is a proposal for a native progress-tracking mechanism in Swift.
>
> Motivation:
>
> As most of us know, Foundation includes NSProgress, a class that makes
> it fairly simple to implement progress tracking in complex
> applications. What makes NSProgress nice is the way that it builds a
> tree of progress objects, so that each method or function only needs
> to take into account the work that needs to be done in that specific
> context, and the parent NSProgress object can interpret that progress
> in the context of its own work to be done. NSProgress also provides a
> support for common issues like checking whether the user has cancelled
> an operation. However, there are a few issues with NSProgress:
>
> 1. Because NSProgress relies on a thread-local global variable to
> store the current progress object, it is impossible to know, outside
> of documentation, whether any particular method supports NSProgress or
> not, and if the documentation is inadequate, one must resort to
> trial-and-error to determine whether NSProgress is supported (this has
> been noted before: http://oleb.net/blog/2014/03/nsprogress/).
>
> 2. NSProgress relies on a paired register-deregister pattern in order
> to obtain and release "current" status. One must balance every
> becomeCurrentWithPendingUnitCount() call with a resignCurrent() call,
> or Bad Thingsā„¢ will happen. However, this pattern does not work well
> with Swift's error handling mechanism:
>
> progress.becomeCurrentWithPendingUnitCount(1)
> try self.doSomeThingThatMightThrow()
> progress.resignCurrent() // If an error occurs, this will never be
> called!
>
> 3. It is very easy to accidentally add a child NSProgress object if
> one does not realize that a particular method supports NSProgress. A
> couple ways this can happen:
>
> func doSomething() {
>     let progress = NSProgress(totalUnitCount: 10)
>
>     progress.becomeCurrentWithPendingUnitCount(10)
>     doSomethingThatSupportsNSProgress()
>     doSomethingThatWeDontExpectSupportsNSProgressButDoes() // whoops,
> we just picked up a child accidentally
>     progress.resignCurrent()
> }
>
> func doSomething() {
>     let progress = NSProgress(totalUnitCount: 10)
>
>     progress.becomeCurrentWithPendingUnitCount(10)
>     doSomethingThatSupportsNSProgress()
>     progress.resignCurrent()
>
>     doSomethingThatWeDontExpectSupportsNSProgressButDoes() // whoops,
> this one just picked up our parent's NSProgress and made it our
> sibling
> }
>
> This is particularly problematic when you consider the obvious
> workaround for the problem described in #2:
>
> func doSomething() {
>     let progress = NSProgress(totalUnitCount: 10)
>
>     progress.becomeCurrentWithPendingUnitCount(10)
>     defer { progress.resignCurrent() }
>
>     doSomethingThatSupportsNSProgress()
>     doSomethingThatWeDontExpectSupportsNSProgressButDoes() // whoops
> }
>
> I haven't figured out exactly how to reproduce this reliably yet, but
> I have noticed that NSProgress objects can sometimes end up with a
> fractionCompleted larger than 1.0 when this occurs.
>
> 4. Because NSProgress uses a thread-local global variable for the
> current progress, one must be vigilant about jumping through specific
> hoops when spinning off a task in a new thread, or else the child
> task's NSProgress object will not be properly connected to its parent.
>
> 5. NSProgress posts KVO notifications on the main thread. In addition
> to complicating the interface and causing undefined behavior if one
> mistakenly binds it to a UI element without inserting something in
> between to forward the KVO notifications to the main thread, this also
> hurts performance in the worker thread, since KVO notifications carry
> a non-trivial performance penalty, involving not only message sends
> but also packaging things into an NSDictionary for the "change"
> parameter. Notifications for multiple properties are fired every time
> completedUnitCount is changed, which then can cause similar KVO
> notifications to occur up the family tree, depending on how many KVO
> observers are installed. This can show up as a non-trivial performance
> cost in Instruments if the NSProgress object is updated frequently
> enough.
>
> Proposed Solution:
>
> I propose a system that follows the example of Swift's excellent
> do/try/catch error-handling system. A "reports" keyword can be added
> to a function or method's declaration, similar to "throws":
>
> func myFunc() reports -> () {
>     ...
> }
>
> By default, the "reports" keyword will introduce a "let" constant
> named "progress" to the function's scope, much like "catch" implicitly
> adds "error". The name of this constant could be customized, if
> desired:
>
> func myFunc() reports(myNiftyProgressObject) {
>     ...
> }
>
> The created progress object is automatically added as a child to the
> progress object which is implicitly passed into the function by the
> calling function or method.
>
> The interface to the progress object would be similar to NSProgress,
> with a "prepare" method taking the total unit count, as well as a
> "cancelled" property and a "cancel()" function helping keep track of
> whether or not an operation is cancelled. However, the "becomeCurrent"
> and "resignCurrent" methods are replaced with a "report" keyword which
> precedes a function or method call, similar to "try", although the
> "report" keyword includes a parameter taking the number of units
> corresponding to this operation:
>
> func myFunc() throws, reports {
>     progress.prepare(10)
>
>     report(5) foo()
>
>     if progress.cancelled { throw NSCocoaError.UserCancelledError }
>
>     report(5) try bar() // yes, "report" can be combined with "try"
> }
>
> Spinning off an operation onto another thread or queue is no problem:
>
> func myFunc() reports {
>     progress.prepare(10)
>
>     dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT,
> 0)) {
>         report(10) foo() // the progress object is passed as a
> parameter to the function, so no need to specially make it current on
> this thread first
>     }
> }
>
> Unlike "try", "report" is not required. If we don't care about the
> progress of a method or function, we can call it without "report" and
> its progress will be ignored. In this example, the
> "someRelativelyTrivialOperationThatReports"'s progress object will
> *not* be added as a child to "progress":
>
> func myFunc() reports {
>     progress.prepare(10)
>
>     someRelativelyTrivialOperationThatReports()
>     report(10) someNonTrivialOperationThatReports()
> }
>
> If a function calls only one reporting function, the "prepare" method
> can be omitted and the parameter can be left off of "report", and the
> parent progress object will simply be passed through:
>
> func myFunc() reports {
>     report doAllTheActualWork()
> }
>
> To pass in a progress object other than the default progress object
> (for example, when creating the object at the very top of the tree),
> simply pass the progress object in as an optional second parameter to
> "reports":
>
> func startEverything() {
>     let progress = Progress()
>
>     progress.prepare(10)
>
>     report(10, progress) someFuncThatReports()
> }
>
> In the function that does the actual work, of course the progress
> object has a "completed" property that tracks the completed units:
>
> func doTheActualWork() reports {
>     let someArrayOfThings = ...
>
>     progress.prepare(someArrayOfThings.count)
>
>     for eachThing in someArrayOfThings {
>         // do the work
>
>         progress.completed += 1
>     }
> }
>
> Observing the progress object uses a closure-based system, containing
> a "willUpdateHandler", a "didUpdateHandler", and a "cancelHandler":
>
> progress.didUpdateHandler = { (completed: Int, total: Int) -> () in
>     print("\(completed) units complete out of \(total)")
> }
>
> progress.cancelHandler = {
>     print("the user cancelled the operation")
> }
>
> The separation between will- and did- handlers is to facilitate
> interoperability with NSProgressIndicator and other Objective-C
> objects; one could call willChangeValueForKey() and
> didChangeValueForKey() in the handlers to let the KVO system know
> about the changes if needed. If desired, a small wrapper could be
> added to Foundation to translate the progress object's notifications
> into KVO notifications.
>
> The progress object also includes a property specifying the dispatch
> queue on which the handlers will be run. By default this is the main
> queue, but this can be customized as needed:
>
> progress.dispatchQueue = myDispatchQueue
>
> Of course, any changes to the "completed" property in a child progress
> object would be bubbled up the family tree, causing any handlers on
> the parent progress objects to fire, and any changes to the
> "cancelled" property in a parent progress object would be propagated
> to its children, similar to how NSProgress currently works.
>
> If there is interest in this, I could flesh out the interface and
> implementation to the progress object a bit.
>
> What do you all think?

I think this is really out-of-scope for swift-evolution.  There's little
chance we'd want something like this in the standard library in the near
term.  It's really Foundation's domain.

-Dave



More information about the swift-evolution mailing list