ROP: Railway Oriented Programming in Swift

This is my first attempt to implement ROP while learning swift…

…and escape from the pyramid of doom! :wink:

TL;DR

R.O.P. Railway Oriented Programming is a technique to manage complex chains of transformations and checks focusing only on the “happy path” (the good = green rail) and pretending that the errors (the bad = red rail) does not happens.
Using it is very simple: you need only to “wrap” your return types into a Result and returning good or bad things.



IE: a simple function

func check(_ x: Int) -> Result<Int> {
    if x>10 {
        return good(x)
    } else {
        return bad("X should be greater than 10")
    }
}

IE: a sequence of functions with a mix of normal and ROP functions…

let complexComputation = good(22)
                        .then(check)
                        .then{$0/11} // Note that this is a closure
                        .then{x in "The result is \(x)!"}
print(complexComputation)

Ok(“The result is 2!”)

If something goes bad computation does not go wrong and we’ve a meaningful error…

let miserableMistake = good(7)
                       .then(check) // check function act as a railway exchange
                       .then{$0/11} // Note that this is a closure
                       .then{x in "The result is \(x)!"}
print(miserableMistake)

Error(__lldb_expr_21.SimpleError(msg: “X should be greater than 10”))

WARNING: I’ve found some problem with embedded images: should fix them soon.

Open in Colab:
https://colab.research.google.com/github/artste/swift-rop/blob/master/jupyter/rop.ipynb#scrollTo=kdMSCmBxZv4A

Github Repo:

4 Likes

Really great explanation! This approach is also very useful for asynchronous tasks, where you need to wait for some things to finish before you can run other tasks. Tasks can of course be modelled as closures, and you can chain your desired operations in a similar fashion. Futures / Promises frameworks extend this concept to deal with results that may or may not be available at a given point.

1 Like

this is so cool, wonder what’s the different to Rx approach?

Q: what happens when you hit bad? does it stop? then what happens if you want to do something special in response to bad?

Cool. If you’re interested, here is a similar blog post.

We aren’t going to be talking about optionals today, but the optional chaining operator does exactly this in a very nice way:

x?.foo()?.bar()?.baz()

Stops the trains after any of x, foo(), bar() or baz() return nil (aka null).

-Chris

5 Likes

this is so cool, wonder what’s the different to Rx approach?

AFAIK, Rx is an implementation of Functional Reactive Programming paradigm: that focus on stream processing and asynchronous events.
(Functional Reactive Animation - https://en.wikipedia.org/wiki/Functional_reactive_programming)

TL;DR: ROP is an optional with the “reason” when the result is nil.
The ROP is a revisitation of classical “Either” (Monad) proposed by Scott Wlaschin (https://fsharpforfunandprofit.com/rop/) and focused on the error handling.
Instead of classical if-the-else pyramid, you can reason on the “happy path” only, pretending that everything works, and if any of your “functions” breaks your computation train change the rail and you know why (looking at the error).

As @clattner said, you can have same behaviour with the optional chaining operator a()?.foo().

Q: what happens when you hit bad ? does it stop? then what happens if you want to do something special in response to bad ?

When the bad happens, you can take a look at your final result, but the current “then” operator is made to interrupt the computation.

1 Like

EXAMPLE 1: simple shellCommand replacement

TL;DR: use ROP to track when a shellCommand command has completed correctly.

It’s very easy to refactor the existing code to add ROP:

  • Change return type: String -> Result<String>
  • the good: replace the good return good(theGoodResult)
  • the bad: all the time that the system has unwanted behaviour return bad(“Meaningful message”)
//export
@discardableResult
public func shellCommandROP(_ launchPath: String, _ arguments: [String]) -> Result<String>
{
    let task = Process()
    task.executableURL = URL(fileURLWithPath: launchPath)
    task.arguments = arguments

    let pipe = Pipe()
    task.standardOutput = pipe
    do {
        try task.run()
    } catch {
        return bad("ERROR: \(error).") // Incredible, to convert it I've just changed print to "bad"
    }

    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    return good(String(data: data, encoding: String.Encoding.utf8) ?? "")
}

IMPORTANT: Now our shellCommand is “aware of errors”. So all the time we call it we’re going to understand if something good or bad has happened.

let resultOfShellCommand = shellCommandROP("/bin/ls", ["-lh"])
print("Was last command successful?! Answer: \(resultOfShellCommand.isOk())")

Was last command successful?! Answer: true

let resultOfShellCommand = shellCommandROP("/bin/abc", ["-lh"])
print("Was last command successful?! Answer: \(resultOfShellCommand.isOk())")

Was last command successful?! Answer: false

// Or extract the string in a more "understandable way"
print(shellCommandROP("/bin/ls", ["-lh"]).value!)

total 14M
-rw-rw-r-- 1 1000 1000 32K Apr 23 21:36 00_load_data.ipynb
-rw-rw-r-- 1 root root 21K Apr 23 00:45 00_load_data_rop.ipynb
-rw-rw-r-- 1 1000 1000 38K Apr 22 06:00 00a_intro_and_float.ipynb

TO BE CONTINUED…

1 Like

EXAMPLE 2: error and result aware shellCommand

TL;DR

This complete example shows how to use ROP to create a “shellCommand” utility function that is aware of bot errors (misspelled commands and exceptions) and command result (standard unix convention where:‘0’ means OK).

func countNumberOfFilesInFolder(_ folder: String) -> Result<Int> {
    return shellCommandROP("/bin/ls", [folder])
            .then(myLinesCounter)
}
printResult("1) Run on existing folder:", countNumberOfFilesInFolder("/notebooks/swift-rop/jupyter"))
printResult("2) Run on wrong folder: ", countNumberOfFilesInFolder("/wtf"))
  1. Run on existing folder: 3
  2. Run on wrong folder: ERROR: [2]: /bin/ls: cannot access ‘/wtf’: No such file or directory

In this way you can chain multiple commands

Longer chain

func countNumberOfFilesInCurrentFolder() -> Result<Int> {
    return shellCommandROP("/bin/pwd")
        .then{pwd in pwd.replacingOccurrences(of: "\n", with: "")} // Use previous result 
        .then{pwd in shellCommandROP("/bin/ls", [pwd])} // Use the clean string to search files
        .then(myLinesCounter)
}
let complexResult = countNumberOfFilesInCurrentFolder(); 
printResult("The count of number of files in ls is:", complexResult)

The count of number of files in ls is: 3

Use cases

let theFolder = "/notebooks/swift-rop/jupyter/tmp"
let theFile = theFolder + "/test.txt"
print("1) create the folder \(theFolder)",shellCommandROP("/bin/mkdir",[theFolder]))
print("2) list files in the folder",shellCommandROP("/bin/ls", [theFolder])) 
print("3) create the file \(theFile)",shellCommandROP("/bin/touch", [theFile])) 
print("4) run non existent command",shellCommandROP("/bin/wtf", [theFile])) // wtf is not a command
printResult("5) list files again. Folder content:",shellCommandROP("/bin/ls", ["-lh",theFolder])) // list files in new folder
print("6) remove the file",shellCommandROP("/bin/rm", ["-rf", theFile]))
printResult("7) Folder content:",shellCommandROP("/bin/ls", ["-lh",theFolder])) // list files in new folder
print("8) remove the folder",shellCommandROP("/bin/rmdir",[theFolder]))
print("9) try to list files in deleted folder",shellCommandROP("/bin/ls", [theFolder]))
1) create the folder /notebooks/swift-rop/jupyter/tmp Ok("")
2) list files in the folder Ok("")
3) create the file /notebooks/swift-rop/jupyter/tmp/test.txt Ok("")
4) run non existent command Error(__lldb_expr_15.SimpleError(msg: "ERROR: The operation could not be completed."))
5) list files again. Folder content: total 0
-rw-r--r-- 1 root root 0 Apr 26 15:41 test.txt

6) remove the file Ok("")
7) Folder content: total 0

8) remove the folder Ok("")
9) try to list files in deleted folder Error(__lldb_expr_21.ShellCommandError(code: 2, msg: "/bin/ls: cannot access \'/notebooks/swift-rop/jupyter/tmp\': No such file or directory\n"))

NOTE: on command (4) we have a misspell error, while on command (9) we call a correct command, but on an illegal folder (The error holds error code 2 and descriptive text).

Source code:

Unix ACK wrapper

This is an example of shellCommandROP usage: we wrap the “ack” command and with full error and result behaviour.

With this command you can search the fast.ai source code within jupyer :wink:

ack(word: "model", path: "/notebooks/fastai_docs/dev_swift/FastaiNotebook_08_data_block") // Search in folder

Returns:

ack(word: "xxxx", path: "/asd/asd") // No result

Returns:

Source code:

1 Like

Rop published as swift library!

Using ROP in swift has never been easyer:

  1. Install the “Rop” package
%install-location $cwd/swift-install
%install '.package(url: "https://github.com/artste/swift-rop", from: "0.1.0")' Rop
  1. Use it!
import Rop

func unixPwd() -> String {
    return shell("/bin/pwd")    
        .then{pwd in pwd.replacingOccurrences(of: "\n", with: "")} // Clean pwd output form extra "\n"
        .value! // Should be safe: usually pwd is not nil ;-)
}

Test it…

print("'\(unixPwd())'")

‘/notebooks/swift-rop/jupyter’

if you make a mistake…

print("'\(unixPwd())'")

‘/notebooks/swift-rop/jupyter’

The whole ack command wrapper

import Foundation

typealias AckResults = Result<[AckResult]>

func printAckResults(ackrRes: AckResults, printFullPath: Bool) {
    if let ackr=ackrRes.value {
        for row in ackr {
            let theFileName = (printFullPath) ? row.fileName : (row.fileName as NSString).lastPathComponent
            print(theFileName, "R:\(row.row)", row.sample)
        }
    } else { print(">> No results found <<") }
}

func ack(word: String, path: String? = nil, caseInsensitive: Bool = true, show: Bool = true, printFullPath: Bool = false) -> AckResults {
    let thePath = path ?? unixPwd()
    let args = good([word, thePath])
        .then{args in caseInsensitive ? args + ["-i"] : args}
    let ret = shell("/usr/bin/ack", args.value!)
        .then{str in str.components(separatedBy: "\n")}
        //.use{lines in print(lines)} // Debug example
        .then{lines in lines.map(processLine)}    
        .then{rets in rets.filter{r in r.fileName != "?"}} // Filtering out errors
    if show && ret.isOk() { printAckResults(ackrRes: ret, printFullPath: printFullPath) }
    return ret
}

#Test it...
ack(word: "float", path: "/notebooks/fastai_docs/dev_swift/FastaiNotebook_08_data_block")

Acknowledgments

Thanx to @pcuenq for the help!

1 Like