-
-
Save andymatuschak/2b311461caf740f5726f to your computer and use it in GitHub Desktop.
struct User { | |
let id: Int | |
let name: String | |
let email: String? | |
} | |
extension User: JSONDecodable { | |
static func create(id: Int, name: String, email: String?) -> User { | |
return User(id: id, name: name, email: email) | |
} | |
static func decode(json: JSONValue) { | |
return check(User.create, json["id"], json["name"], json["email"]) | |
// check() calls its fn only if the required arguments are non-nil | |
// You could readily define check() as an infix operator that takes a tuple, e.g.: | |
// return User.create?<(json["id"], json["name"], json["email"]) | |
} | |
} | |
protocol JSONDecodeable { | |
class func decode(json: JSONValue) -> Self? | |
} | |
enum JSONValue { | |
case JSONObject([String: JSONValue]) | |
case JSONArray([JSONValue]) | |
// etc | |
subscript(key: String) -> Int {} | |
subscript(key: String) -> Bool {} | |
// etc | |
} | |
func check<A, B, C, R>(fn: (A,B,C) -> R, a: A?, b: B?, c: C?) -> R? { | |
if a == nil || b == nil || c == nil { | |
return nil | |
} else { | |
return fn(a!, b!, c!) | |
} | |
} | |
func check<A, B, C, R>(fn: (A?,B,C) -> R, a: A?, b: B?, c: C?) -> R? { | |
if b == nil || c == nil { | |
return nil | |
} else { | |
return fn(a, b!, c!) | |
} | |
} | |
func check<A, B, C, R>(fn: (A,B?,C) -> R, a: A?, b: B?, c: C?) -> R? { | |
if a == nil || c == nil { | |
return nil | |
} else { | |
return fn(a!, b, c!) | |
} | |
} | |
func check<A, B, C, R>(fn: (A,B,C?) -> R, a: A?, b: B?, c: C?) -> R? { | |
if a == nil || b == nil { | |
return nil | |
} else { | |
return fn(a!, b!, c) | |
} | |
} | |
func check<A, B, C, R>(fn: (A?,B?,C) -> R, a: A?, b: B?, c: C?) -> R? { | |
if c == nil { | |
return nil | |
} else { | |
return fn(a, b, c!) | |
} | |
} | |
func check<A, B, C, R>(fn: (A?,B,C?) -> R, a: A?, b: B?, c: C?) -> R? { | |
if b == nil { | |
return nil | |
} else { | |
return fn(a, b!, c) | |
} | |
} | |
func check<A, B, C, R>(fn: (A,B?,C?) -> R, a: A?, b: B?, c: C?) -> R? { | |
if a == nil { | |
return nil | |
} else { | |
return fn(a!, b, c) | |
} | |
} | |
func check<A, B, C, R>(fn: (A?,B?,C?) -> R, a: A?, b: B?, c: C?) -> R? { | |
return fn(a, b, c) | |
} | |
// etc. |
You can do that using switch patterns. IIRC this should work now:
switch (json["id"], json["name"]) {
case let (id as Int, name as String):
...
default:
...
}
Eh, if all the check implementations are in a library, why do you care how many there are?
@jckarter The compiler won't be able to determine which implementation of subscript
to call in that case (i.e. Int
-returning vs. String
-returning), right?
"Eh, if all the check implementations are in a library, why do you care how many there are?"
That argument makes good practical sense, I guess it's just a deeply held 'code smell' prejudice against any time I start cutting and pasting code with minor variations each time.
I agree that it's worth weighing the cost of each approach (custom operators vs. something like this). Sure, it's tedious to write this way the first time, but to new people picking up the code base, this will be highly readable and easy to understand quickly. I don't even have to read past the first few overloads before I'm comfortable saying "Got it! Moving on"
The delicious custom operators are undoubtably 'far more expensive' for readability with new devs, even if they are more personally rewarding to understand.
I guess the end result in actual client code that calls this stuff is more or less the same syntactic simplicity with either approach, so as a cost/benefit discussion in a professional engineering shop, I can definitely see this pragmatic approach winning out.
Yeah, the intent is not that developers would read these combinatoric overloads. They can even be machine-generated. The result is ugly—I agree—but not that different from e.g. this ugliness in Haskell: https://hackage.haskell.org/package/aeson-0.8.0.2/docs/Data-Aeson-Types.html#t:FromJSON
I think it's worth remembering that all of this is to avoid the following:
static func decode(json: JSONValue) -> User? {
if let id: Int = json["id"] {
if let name: String = json["name"] {
return User(id: id, name: name, email: json["email"])
}
}
return nil
}
(I usually use [String:AnyObject]
here since that's what comes out of NSJSONSerialization
and write these as if let id = json["id"] as? Int
, but I'm matching your example.)
I fear we're falling into a pit of creating lot of complexity to avoid a small amount of complexity. Is it really worth it in today's Swift? I'm not saying that there aren't some things the Swift language could change to make all of this more beautiful, but is the above decode()
really something that we need to spend a lot of time avoiding with workarounds of varying ugliness and compiler-breakingness?
Take this to another level and try adding some error handling to both your example and mine. What code do I need to add so I could return a useful NSError
indicating what went wrong? I think the check
solution makes that very difficult (the operator solutions even more so).
The key simplification is factoring all this if-let-if-let into a single function that returns an optional. Once we did that (and we're all doing that), I am beginning to believe that further simplification is counter-productive (I'm even starting to question my own contributions here.)
All the examples around this are in JSON parsing. Maybe we just accept that JSON parsing uses a lot of if-let, and make sure to parse JSON into structs in one place so that doesn't bother other code. We should be doing that anyway.
That's a totally fair point, @rnapier! My current production project has the kind of code you wrote there, and it's honestly not that bad.
Take this to another level and try adding some error handling to both your example and mine. What code do I need to add so I could return a useful NSError indicating what went wrong? I think the check solution makes that very difficult (the operator solutions even more so).
I actually think check
could handle this nicely by providing Either
variants instead of Optional
s. The subscripts would then return Either
s that contain parse error info.
You know, if you bring error handling into the mix, and start requiring Either
s or something, there's no good way to use if-let
...
I was just experimenting with exactly this question :D I've been working extensively with an Either
called Result
(see https://github.com/LlamaKit/LlamaKit/blob/master/LlamaKit/Result.swift). My experience so far is that Result
is incredibly useful when it's being passed to closures. But for simpler code, the difference between Result
and "return T?
and pass an NSErrorPtr
" is pretty much a toss up. Compare pagesFromOpenSearchData
with Result
versus "the Cocoa way" (with NSErrorPointer
). Then compare the "helpers" that get us there (with and without). I don't think there's a real clear winner here. Result
is a little nicer to process, but a little harder to construct. (Plus the need for a Result
definition.)
Note that these use an Optional.flatMap
extension:
func pagesFromOpenSearchData(data: NSData) -> Result<[Page]> {
var error: NSError?
let pages = asJSON(data, &error)
.flatMap { asJSONArray($0, &error) }
.flatMap { atIndex($0, 1, &error) }.flatMap{ asStringList($0, &error) }
.flatMap { asPages($0) }
return Result(value: pages, error: error)
}
But here's what the code looks like without that:
func pagesFromOpenSearchData(data: NSData) -> Result<[Page]> {
var error: NSError?
if let json: JSON = asJSON(data, &error) {
if let array = asJSONArray(json, &error) {
if let second: JSON = atIndex(array, 1, &error) {
if let stringList = asStringList(second, &error) {
return success(asPages(stringList))
}}}}
return failure(error!)
}
It's not quite as beautiful, but I don't think it's horrible (the !
is horrible, but the other code has a hidden precondition
in it that's just as bad). It might be a little easier to read with different spacing:
func pagesFromOpenSearchData(data: NSData) -> Result<[Page]> {
var error: NSError?
if let json: JSON = asJSON(data, &error) {
if let array = asJSONArray(json, &error) {
if let second: JSON = atIndex(array, 1, &error) {
if let stringList = asStringList(second, &error) {
return success(asPages(stringList))
}}}}
return failure(error!)
}
(I still prefer adding flatMap to Optional.)
That said, when I tried to rewrite my Operation
(which is a primitive form of Future) using NSErrorPointer
, it was a mess IMO (at least in my initial attempts). I found passing NSErrorPointer
to a closure completely unworkable. And I believe that returning (T?, NSError?)
is a major mistake.
So the nice thing about Result
is that it scales to many more use cases, but I think it creates some overhead in simple use cases since it mismatches with Cocoa and isn't in stdlib. And that keeps bringing me back to the question of whether it's worth it in general, or only when it's an obvious win. (Read another way: what should be teaching students?)
The problem with code that takes an NSErrorPointer
and returns an optional is that the compiler can’t enforce the combination of the values makes sense. There are two fields, and therefore 4 cases, but only 2 of them make sense (value present, error nil, or value nil and error present), the other two cases are bogus (both value and error are nil, or both value and error are present), which could cause weirdness at runtime. This is precisely the benefit that the improved Swift type system can bring: being able to reason about code knowing that only correct cases are possible at runtime.
We can implement flatMap
on Either
(Result
) in the example above, and we would end up with a very simple solution:
enum Result<T> {
case Value(T)
case Error(NSError)
}
extension Result {
func flatMap<U>(f: T -> Result<U>) -> Result<U> {
switch (self) {
case let Value(value):
return f(value)
case let Error(error):
return Result<U>.Error(error)
}
}
}
func pagesFromOpenSearchData(data: NSData) -> Result<[Page]> {
let pages = asJSON(data)
.flatMap { asJSONArray($0) }
.flatMap { atIndex($0, 1) }
.flatMap { asStringList($0) }
.flatMap { asPages($0) }
return pages
}
Hello,
Just a quick note to tell you that for checks up to n parameters there are 2^1 + 2^2 + 2^3+....+2^(n-1)+2^n distinct combinations. I will just say(without going in to the details about how I found out) that the current Swift compiler is not really up to the task.
I believe that it might be a good approach for very practical reasons but unfortunately it is un-feasable (today?).
What might work is to use check functions up to 4-5 parameters and handle the rest manually.
What is interesting to me is the very declarative style and lack of voodoo to make the mapping work. It looks to me really easy to debug. Even more interesting is the error handling version which could produce very precise error messages so one doesn't need anymore to trace the code in order to understand errors.
Adrian
Thinking about it further (from Twitter), I’d be happy if this worked as needed:
Of course, making it a statement and having all the types inferred is nicer, but probably not (2^(n + 1) − 1, for n = maximum allowed number of arguments) implementations of
check()
nicer.