Variants
Variants are a way to maintain type safety while representing variable state. The best way to explain this is by example:
struct StoreItem {
variant Available {
name: string
price: dec
)
variant Missing {
name: string
}
}
fn main() {
var item: StoreItem.Missing(name: "CLIF Bars")
item->Available(
name: item.name
price: 2.0
)
}
->
syntax is better than vary
keyword/block, and it probably means we don't
need match
either (can use if
and switch
).
The compiler doesn't need to keep track of what variant type is active in every
scope, because the reference/ownership rules save us from potential conflicts.
So normal async
/gen
rules apply, nothing special.
Note, ->
is a statement, not an expression. There is no value and thus it
can't be used in an expression and can't be assigned to. For example, this
will return an error:
store_result->FullyStocked.total += 3 + item->Available.total
->
can be used to vary an existing variant though, and if you like, you can
wrap it in an iferr
:
fn append!(store: *Store) {
if (store->NotListening) { # This is cool
iferr(store->Listening(name: store.name
prices: store.prices
channel: store.channel
store.listener: store.listen()) {
echo("Store failed to start listening: ${e.msg}")
return
}
}
StoreList.append(*store)
}
Also variant fields should be optional if their name and type match:
fn append!(store: *Store) {
if (store->NotListening) { # This is cool
iferr(store->Listening(store.listener: store.listen()) {
echo("Store failed to start listening: ${e.msg}")
return
}
}
StoreList.append(*store)
}
Old Variants
Variants are a way to maintain type safety while representing variable state. The best way to explain this is by example:
type Path (string) {
:get {
exists: os.path.exists(:base)
is_file: os.path.is_file(:base)
is_folder: os.path.isdir(:base)
readable: os.path.access(:base, "r")
writable: os.path.access(:base, "w")
executable: os.path.access(:base, "x")
}
}
const FileModeString (string) {
ReadWriteExecute: "rwx"
ReadWrite: "rw"
ReadExecute: "rx"
WriteExecute: "wx"
Execute: "x"
}
type Data {
:fields {
is_null: bool(true)
:variants {
switch (is_null) {
case true {
is_null: bool(true)
error: Error
}
case false {
is_null: bool(false)
data: StringBuffer
}
}
}
}
static from_file(File file) (Data) {
with (file.read() as file_data) {
return Data(
is_null: false,
data: StringBuffer(file_data)
)
}
else {
return Data(
is_null: true,
error: :error
)
}
}
}
flag FileMode {
Read
Write
Execute
fn <FileModeString>() {
switch (:base) {
case Read | Write | Execute {
return ReadWriteExecute
}
case Read | Write | Execute {
return ReadWriteExecute
}
case Read | Write {
return ReadWrite
}
case Read | Execute {
return ReadExecute
}
case Write | Execute {
return WriteExecute
}
case Execute {
return Execute
}
}
}
}
const FileState {
Closed
Open
Error
}
type File {
:fields {
FileState state: Closed
:variants {
switch (state) {
case FileState.Open {
state: FileState.Closed
handle: sys.stdio.FileObject
}
case FileState.Error {
state: FileState.Error
error: Error
}
}
}
}
static open(Path path, FileMode mode) (File) {
with (sys.stdio.fopen(path, mode) as file_handle) {
return File(
state: FileState.Open,
handle: file_handle
)
}
else {
return File(
state: FileState.Error,
e: :error
)
}
}
fn read() (Data) {
return Data.from_file(self)
}
fn close() {
sys.stdio.fclose(self.file_object)
}
}
fn echo_a_file_using_context_errors() {
file = File.open(
path: Path('/etc/passwd'),
mode: FileMode.Read
)
if (file.state == FileState.Error) {
die("Opening ${file.path} failed (${file.error}), try again later")
}
Data file_data = file.read()
if (file_data.is_null) {
die("Reading ${file.path} failed (${e}), try again later")
}
echo(data)
file.close()
}
This idiom has some similarities with exceptions and optional return types.
Like exceptions, it allows you to avoid the "pyramid of doom": a phenomenon
where your code moves further and further to the right as you handle all the
potential error cases. Pyramids of doom are still possible because with
blocks (and try
blocks) can be nested, but it's very bad practice in general.
Exceptions in most languages will let you leave variables uninitialized
however. In Ki, this isn't possible; if code later tries to access
file.handle
, it is a compile-time error. This is similar to optional return
types, where you handle the different return types right there in the function
call.
The main difference, and the standout feature, is that there is never any
question about return types. file
is always assigned a File
instance,
even if this instance might vary from other instances. It isn't a NULL
pointer (also not allowed in Ki), it isn't a special, reserved type of File
that means an error occurred, and it isn't a special "error" instance.