work in progress
This document is a thought experiment in progress. I am trying to test the current idea I had about exceptions. I am fully aware, that it might be insufficient, not solution at all or worse than status quo that I am criticising (to see what really is the problem, or I think it is). I am testing it out by writing this doc
When I started writing Rye I had no specific ideas about Exception handling. But the classic try catch model looked annoying to me.
I had a problem with it visually, the code without exceptions flowed, but if you added all exception handling that needed to be there (many times) the code became a mess. Try catch added code structure, where there logic wise wasn’t one. And it’s sort of GOTO-ish structure at that.
Try catch statements are often times not specific enough, and at the same time much too verbose.
This is an example from python docs:
try:
f = open('myfile.txt')
s = f.readline()
i = int(s.strip())
except OSError as err:
print("OS error: {0}".format(err))
except ValueError:
print("Could not convert data to an integer.")
except:
print("Unexpected error:", sys.exc_info()[0])
raise
The problems I have with this:
I wished to make something that is visually and structurally not so obtrusive, it shouldn’t require / create it’s own code structure. I wished for something in-flow.
To have these two, the “try” shouldn’t accept block of code (structure). To be in flow, an exception should a value like others, that we can handle …
Everything below this point is just a current hypothesis, it may be wrong in parts or all-together, but let’s play with it and see.
Let’s try to start from scratch …
Value and IO exceptions start as failures. Failure to do the desired operation. If failure is not handeled or returned it becomes an program error and stops the execution. So failures can happen and we can handle them, unhandled failure is an error, a programming bug.
All programming is or should be is a translation from computer to user / domain language, from machine code to UI basically, programming is somewhere in between.
Handling exceptions is the same. A machine failure happens and we translate it to user / domain and then display it to user. Example:
very simple exception, we just print to user directly:
try:
x = int(raw_input("Please enter a number: "))
except ValueError:
print("You didn't enter a number")
else:
print("I raise by 100 to %d" % (x + 100) )
input "Please enter a number:" |to-int
|fix-either
{ "You didn't enter a number" }
{ + 100 |str-val "I raise by 100 to {#}." }
|print
simple exception in a loop, from python docs (https://docs.python.org/3/tutorial/errors.html)
while True:
try:
x = int(input("Please enter a number: "))
break
except ValueError:
print("Oops! That was no valid number. Try again...")
while {
input "Please enter a number:" |to-int
|fix-either
{ print "This was not a valid number. Try again" }
{ .print-val "You entered number {#}." false }
}
While in rye repeats until while return of a block is truthy.
File IO and conversion to Int …
try:
f = open('myfile.txt')
s = f.readline()
i = int(s.strip())
except OSError as err:
print("OS error: {0}".format(err))
except ValueError:
print("Could not convert data to an integer.")
except:
print("Unexpected error:", sys.exc_info()[0])
raise
^check is a “returning function” that can also return to caller. It works like this. It accepts 2 arguments, if first is a failure it wraps it into an error created from second argument and returns to caller (exits current evaluation unit). If first argument is not failure it returns it.
open %myfile.txt
|^check "Failed to open profile file"
|readline
|^check "Failed to read profile file"
|strip
|to-int
|^check "Failed to convert age to integer"
Why I feel rye version is better:
If we put our code in function the benefit becomes evan more visible:
get-age: does {
open %myfile.txt
|^check "Failed to open profile file"
|readline
|^check "Failed to read profile file"
|strip
|to-int
|^check "Failed to convert age to integer"
}
There are multiple scenarios you would want to do if you counln’t do age. If you want to provide an alternative / default value:
get-age |fix 0 :age
get-age |fix { ask-for-age } :age
If you can’t provide alternative, you usually want to reraise still
get-age |^check "Problem getting user's age"
You can then handle this up-further (closer to user), or the system displays it to the user, with nice nested info:
( Problem getting user’s age ( Failed to open profile file ( myfile.txt doesn’t exist ) ) )
In the examples above I used strings to quickly create failures. But this isn’t ideal, for example what if you want to use code in an application in another language. There are standard “error codes”, I are still determining which standard to use, and there is a short-name option that makes them translatable then.
As I look at the examples for exceptions in languages most of them catch and print the error. These are just examples, but I am not sure if such behaviour doesn’t then extend into real code. All in all I think it’s cumbersome model. If you don’t handle (provide alternative or translate the exception) - what are you then even doing writing code?
It uses a lot of code to create user level inconsistent presenting errors. Each “app” should have one way of presenting errors determined on app level and if you just catch / print and fail there is no point in catching except signaling to your future self that you are aware failure can happen somewhere (you don’t handle it, but you still want to distinguish it from failure you didn’t expect at all (which means you must look at and figure out what to do)).
Scenario: load multiple files, in their own function, translate error messages
scenario goes like this (I have written scenario before I started writting any code to solve it)
{ "username": "Jim", "stream": [ "update1", "update2" ] }
TODO -- add does function
load-user-name: does { read %user-name |fix "Anonymous" }
load-user-stream: does {
read %user-stream-new
|^check "Error reading new stream"
|collect
read %user-stream-old
|^check "Error reading old stream"
|collect
}
load-add-user-data: does {
load-user-name |collect-key 'username
load-user-stream |fix-either
{ .re-fail "Error reading user data" }
{ .collect-key 'stream , collected }
|to-json
}
The aproximate python-like code.
def load_user_name ():
try:
return Path("user-name").read_text()
except:
return "Anonymous"
def load_user_stream ():
stream = []
try:
stream.append(Path("user-stream-new").read_text())
except:
raise "Error reading new stream"
try:
stream.append(Path("user-stream-old").read_text())
catch fileError:
raise "Error reading old stream"
def load_add_user_data ():
data = {}
data["username"] = load_user_name()
try:
data[stream] = load_user_stream()
except:
return json.dumps({ "Error": "Error loading stream" }) # can we get nested error info or just latest?
return json.dumps(data)
What I like about rye-version of code above