I think weighing the pros and cons of computed properties vs methods is not relevant for this proposal, because that is a stylistic choice and is orthogonal to whether the property or subscript is allowed to be throws
or async
(see my reply here).
But, what I think is relevant are the pros and cons of allowing vs disallowing effects specifiers on computed properties or subscripts. Since async
is already covered in the proposal, let's consider throws
on a subscript. We'll start with this simple n-ary tree:
enum Node<T> {
case leaf(T)
indirect case interior(val: T, children: [Node])
}
struct Tree<T> {
private var node : Node<T>
init(_ node: Node<T>) { self.node = node }
}
let tree = Tree(.leaf(5))
and suppose we would like to define a subscript to access a tree's immediate child by an Int
index. There are two reasons why this might fail: you're asking for a child from a leaf node, or there is no child with that index:
enum TreeError : Error {
case NotInteriorNode
case ChildIndexOutOfBounds
}
We could, of course, treat both of these failures opaquely and simply return nil
if the access fails:
extension Tree {
// Version 1: access a child node, indicating an error opaquely with nil
subscript(v1 childIdx : Int) -> Tree<T>? {
get {
switch node {
case .leaf:
return nil
case let .interior(_, children):
guard childIdx < children.count else {
return nil
}
return Tree(children[childIdx])
}
}
}
}
The downside is the loss of information about the failure, but on the plus side, we have ?
to enable a clean syntax for chained accesses:
// no specific error information at the use-sites, but easily chained with `?`
let _ = tree[v1: 0]?[v1: 1]?[v1: 2]
But, if we, as the API authors, would like to inform users of the kind of error, they are stuck with solutions that amount to using Result<>
instead:
extension Tree {
// Version 2: access a child node, indicating the kind of error with Result
subscript(v2 childIdx : Int) -> Result<Tree<T>, Error> {
get {
switch node {
case .leaf:
return .failure(TreeError.NotInteriorNode)
case let .interior(_, children):
guard childIdx < children.count else {
return .failure(TreeError.ChildIndexOutOfBounds)
}
return .success(Tree(children[childIdx]))
}
}
}
}
There are only downsides to this, because both the implementation of the subscript and the use-sites need to deal with wrapping and unwrapping the Result
, and the chained accesses are clunky:
// With Result, using its `get()` method to throw the Error for maximum brevity.
// Awkward to chain accesses and obtain the actual returned value, but does
// preserve the specific Error value so the user can act accordingly.
do {
let _ = try tree[v2: 0].get()[v2: 1].get()[v2: 2].get()
} catch {
// ...
}
This leads API authors to simply returning nil
on failure for computed properties and subscripts, without providing additional information. If the syntactic sugar around unwrapping Optional
is also not desirable, then they're left with invoking a fatalError
, which is what Array
in the standard library does for its subscript(_:Int)
.
This proposal provides a third option by allowing us to define a throwing subscript like so:
extension Tree {
// Version 3: access a child node, indicating the kind of error by throwing
// This is made possible by the effectful properties proposal.
subscript(v3 childIdx : Int) -> Tree<T> {
get throws {
switch node {
case .leaf:
throw TreeError.NotInteriorNode
case let .interior(_, children):
guard childIdx < children.count else {
throw TreeError.ChildIndexOutOfBounds
}
return Tree(children[childIdx])
}
}
}
}
So that we can get the best of everything: a clean chained syntax with error information available (or discardable with try?
), plus no explicit wrapping/unwrapping:
// With the proposal, we get the best of both: clean chaining and accesses,
// while taking full advantage of the error handling mechanisms in Swift.
do {
let _ = try tree[v3: 0][v3: 1][v3: 2]
} catch {
// ...
}
Now, these same arguments for throws
subscripts apply to computed properties too. The only difference is that the source of the error is different: in these examples, the TreeError.ChildIndexOutOfBounds
failure is only due to a bad input to the subscript
from the user. The "inputs" to a computed property are the state of the object, which may be in various kinds of invalid states. For this simple tree example, there is only one invalid state when accessing the value stored at the node:
extension Tree {
// a throwing accessor, instead of using an Optional<T> or Result<T, E>,
// so that use-sites can understand the failure reason.
var value : T {
get throws {
switch node {
case .leaf:
throw TreeError.NotInteriorNode
case let .interior(val, _):
return val
}
}
}
}