[swift-evolution] Pitch: Progress Tracking in Swift
cocoadev at charlessoft.com
cocoadev at charlessoft.com
Sun Jan 17 20:14:47 CST 2016
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?
Charles
More information about the swift-evolution
mailing list