Skip to content

Instantly share code, notes, and snippets.

@andymatuschak
Created December 28, 2014 18:17
Show Gist options
  • Save andymatuschak/2b311461caf740f5726f to your computer and use it in GitHub Desktop.
Save andymatuschak/2b311461caf740f5726f to your computer and use it in GitHub Desktop.
A pragmatic and intentionally non-abstract solution to JSON decoding / initialization that doesn't require learning about five new operators.
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.
@andymatuschak
Copy link
Author

You know, if you bring error handling into the mix, and start requiring Eithers or something, there's no good way to use if-let...

@rnapier
Copy link

rnapier commented Dec 30, 2014

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?)

@JaviSoto
Copy link

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
}

@adriantofan
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment