Error Handling
Ki provides a powerful error management infrastructure that is similar to exceptions, but a little safer and a little more ergonomic.
This is the first time we've used the with
keyword. There are many
situations where the programmer only wants to execute a chunk of code if
something succeeds. with
provides that construct. Conceptually, with
means "execute this block if this statement succeeds". Programmers familiar
with exceptions likely see some similarities with try
. There is more
information on Ki's error handling mechanisms here.
In this case, it's not completely clear what error with
is expecting. That
is because with
is not expecting an error, rather, it is signaling to the
compiler that the following indexing operations on a dynarray
are safe.
Ki programs will never experience an "index out of bounds" error, and it places
most of the burden of that safety on the programmer. In this situation, the
programmer can only index into a dynarray
after they ensure that it has at
least 4 elements. This could be read as, "when trees.ensure_capacity(4)
succeeds, the following indexing operations should be safe". Without a with
statement here, the compiler will return an error. Furthermore, trying to
access an index outside of [0, 3] will also return an error.
There are other restrictions on indexing; for example, using variables instead of literals adds additional complications. For more information, read about Ki's safety mechanisms here.
Errors
An error is a struct
:
struct error {
var message: str
var code: int(-1)
}
type PacketEmpty(error) {}
All errors have a message, but in the case of PacketEmpty
, a message must be
provided as there is no default. This is easy to remedy:
type PacketEmpty(error) {
:fields {
var message: "Packet is empty"
}
}
Setting Error Handlers
Handling every error that occurs can be tedious, so Ki allows the programmer to set default handlers for errors.
fn dont_worry_be_happy(PacketError packet_error) {
echo("Hey there, got a packet error: ${packet_error.message}")
}
fn read_packet(NetChannel nc) {
@handle(PacketEmpty, fn(PacketError packet_error) {
echo("Ugh, another packet error: ${packet_error.message}")
})
nc.load_incoming_packet()
@handle(PacketEmpty, dont_worry_be_happy)
}
Failing
Returning errors is known as "failing" in Ki; when something experiences an error, it fails. This is more or less a synonym for "throwing an exception", and works similarly:
type NetChannel {
fn load_incoming_packet() {
fail(PacketEmpty())
}
fn get_incoming_packet() (Packet) {
fail(PacketEmpty())
}
}
fn dont_worry_be_happy(PacketError packet_error) {
echo("Hey there, got a packet error: ${packet_error.message}")
}
fn main() {
var nc: NetChannel()
@handle(PacketError, dont_worry_be_happy)
iferr (nc.load_incoming_packet() as e) {
echo("Loading incoming packet failed: ${e.message}")
}
with (nc.get_incoming_packet() as packet) {
echo("Got a packet: ${packet.contents}")
}
with (nc.get_incoming_packet() as packet) {
echo("Got a packet: ${packet.contents}")
}
else (as e) {
echo("Getting incoming packet failed: ${e.message}")
}
Here you can see that in the case of load_incoming_packet
we can use iferr
because there is no return value. But we must use with
on
get_incoming_packet
in order to bind the return value. Optionally we can
handle the error in the else
block, but since PacketEmpty
has a default
handler, it's not necessary.
The main goal of this design is to avoid:
- Ignoring errors
- Dangling assignments
- Tons of error-checking code
Most exception or error designs have these problems. Ki's solution can be
verbose, but it's mostly in line with how the rest of the language is
structured (with
blocks, scopes, etc.).