Introduction
Ybixo is a statically typed programming language inspired primarily by Rust. Ybixo programs compile into readable Ruby source code, which can then be executed by the Ruby interpreter.
The language was motivated by the question:
What if a developer wants the high-level features of Rust, but does not need its low-level features, and maximal performance is not a requirement?
Ybixo is free software. You can find its source code for reading and modification in its repository on GitHub: https://github.com/ybixo/ybixo
A quick taste of Ybixo
Here's what Ybixo looks like:
fn greet(name: String) -> String {
"Hello, #{name}!"
}
fn main() {
print_line(greet("Ybixo"))
}
Given a file named intro.ob with this source code, the Ybixo compiler produces the following Ruby:
# frozen_string_literal: true
require_relative "std/all"
module Intro
def self.greet(name)
"Hello, #{name}!"
end
def self.main
::Std::Io.print_line(greet("Ybixo"))
end
main
end
Features
Ybixo offers the following features for productive programming:
- Static typing with type inference
- Parametric polymorphism (generic programming)
- Ad-hoc polymorphism (operator overloading)
- Algebraic data types (product and sum types)
- Type classes (traits)
- Pattern matching
Audience
The book is suitable for readers with or without programming experience. As such, many fundamental programming concepts are explained alongside their implementation in Ybixo.
Caveats
Ybixo is an experimental language and is not intended for use in production projects.
Although it compiles to Ruby, it is not intended to be interoperable with existing Ruby programs.
The language is in early development and many features are not yet implemented. As such, some of the behavior described in this book is aspirational. The plan is to implement the language as described in the book, so anything described here will be supported eventually. That said, all syntactically valid Ybixo source code should compile into equivalent Ruby, but the compiler will allow programs that shouldn't pass type checking, and Ybixo code can "escape" into Ruby not exposed by Ybixo.
All the example programs shown in the book will compile and produce the expected behavior when run, with certain exceptions as noted.
Throughout the book, if a feature being described is notably absent or differs from the current implementation, it will be denoted with a warning like this:
Warning: This feature is not yet implemented.
Conventions
In this book, the following conventions are used.
Terms
Important technical terms are indicated by bold text.
Examples:
- Abstraction is the process of generalizing rules and concepts from specific examples.
- A field is optionally prefixed with a visibility.
Emphasis
Emphasis is indicated by italics.
Example:
- Ybixo is a lot of fun to use!
Source code
Source code appears inline in prose with a monospace font. Multi-line source code appears in blocks with a monospace font and syntax highlighting.
Examples:
-
Inline code appears with
"a monospace font". -
// Multi-line source code appears with fancy(syntax: "highlighting")
Multi-line code blocks can be easily copied to your system clipboard. Hover over the block and click on the clipboard icon that appears in the upper right corner of the block.
Example code
All of the example programs in the book are available in the examples directory of the book's Git repository, which you can find at https://github.com/ybixo/book.
Each multi-line code block corresponding to an example program will begin with a code comment showing the path to that file within the repository:
// File: examples/chapter_01_first/hello_world.ob
Command line interaction
Commands that are intended to be run in a terminal emulator are shown in code blocks, with commands beginning with a $ to indicate the command prompt.
You don't actually type the $ when running the command.
A line beginning with a > indicates additional input you should type after executing the command that will be necessary for the program to proceed.
Lines in the same code block that do not begin with a $ or > indicate the standard output or standard error of the previous command.
Example:
$ obc run hello_world.ob
> Ybixo
Hello, Ybixo!
Installation
Executable commands shown on this page should be run in a terminal emulator on macOS or Linux. This process has not been tested on Windows, but will likely work the same way, especially when using Windows Subsystem for Linux.
Prerequisites
Ybixo requires:
- Git to clone the compiler's source code
- Ruby (version 3.4 or later) to run the programs produced by the Ybixo compiler
- Rust (version 1.65 or later) to build the compiler itself
- Cargo, the build tool for Rust, which will be installed alongside Rust
Installation steps
-
Install Ruby: https://www.ruby-lang.org/en/downloads/
-
Install Rust: https://rust-lang.org/tools/install/
-
Clone the Ybixo Git repository:
$ git clone https://github.com/ybixo/ybixo.git -
From inside the newly cloned repository, execute:
$ cargo install --path .Note the trailing
.at the end of that command.This will build the compiler and place it in Cargo's
bindirectory, which you should add to yourPATHenvironment variable as described in the installation instructions for Rust. -
The Ybixo compiler is now available. Try executing:
$ obc --help
Writing your first Ybixo program
As is tradition, we'll begin learning Ybixo with the "Hello, World!" program.
Create an empty directory to work on the programs in this book.
Inside this new directory, create a new file called hello_world.ob.
Enter the following text into the file:
// File: examples/chapter_01_first/hello_world.ob
// Welcome to Ybixo!
fn main() {
print_line("Hello, World!")
}
Use the Ybixo compiler to run the program and see its output:
$ obc run hello_world.ob
Hello, World!
Remember this command because we'll use it a lot.
This simple program teaches us a few things about Ybixo.
Comments
Two slashes (//) begin a comment. Any text afterwards on the same line will be ignored by the compiler and removed from the Ruby source code produced.
String literals
"Hello, World!"
Strings, sequences of textual characters, are created by writing the text inside a pair of double quotes.
"Literal" just means that there is dedicated syntax in the language to construct a string.
There are other kinds of literals for different types in the language, such as integers (e.g. 123) and boolean values (i.e. true and false).
Functions
fn example() {
expression
}
Functions are declared with the keyword fn, followed by a function name, a pair of parentheses, and a function body inside curly braces.
The body is a sequence of zero or more expressions.
An expression is anything that can be reduced to a single value.
In our program, main has a body consisting of one expression.
Calling functions
print_line("Hello, World!")
A function is called by writing its name followed by any function arguments in parentheses. An argument is input to the function that changes how it behaves.
Writing to stdout
The function print_line is used to write text to the standard output.
This is what causes "Hello, World!" to appear in the terminal when we run our program.
The main function
When the program runs, if a function named main is defined, it will be automatically executed.
Using functions and variables
Let's expand our simple program a bit by using a function to determine who is greeted.
// File: examples/chapter_02_functions/hello_world.ob
fn greet(name: String) -> String {
"Hello, #{name}!"
}
fn main() {
let greeting = greet("Ybixo")
print_line(greeting)
}
Again, use the Ybixo compiler to run the program and see its ouput:
$ obc run hello_world.ob
Hello, Ybixo!
This version of the program teaches us a few more things about Ybixo.
Function parameters
fn greet(name: String)
Function parameters are declared with an identifier beginning with a lowercase letter followed by a colon and then a type identifier beginning with an uppercase letter.
In this case, our function greet has a single parameter called name which must be a String.
Function return values
fn greet(name: String) -> String
The type of value a function returns is declared by an arrow followed by a type identifier, written between the parameter list and the function body's curly braces.
In this case, our function returns a String.
The value returned by a function is its final expression.
In our case, that's the string "Hello, #{name}!".
If we want, we can also use the return keyword to specify the returned value explicitly.
This form can be used to "return early" from a function, even if it's not the last line of the function.
We'll learn in a future chapter why we might want to do so.
return "Hello, #{name}!"
The combination of a function's name, parameters, and return type is called its signature.
Variables
let name = "Ybixo"
A variable is created by binding a value to an identifier using let.
The syntax is the keyword let followed by an identifier, an equals sign, and an expression.
A variable name must start with a lowercase letter and should be written in snake case, using all lowercase letters and separating "words" with an underscore, e.g. example_variable_name.
let is actually much more powerful than this simple form, but we'll explore that further in a few chapters.
String interpolation
"Hello, #{name}!"
Strings can contain interpolations, expressions embedded inside literal text, by delimiting the expression with #{ and }.
These expressions must evaluate to a String.
Controlling flow with conditions
Our program always says "hello" to the person we're greeting.
What if we want to change our greeting depending on who is greeted?
We can do that with conditional expressions, which are expressions that evaluate to boolean values, either true or false.
// File: examples/chapter_03_conditions/hello_world.ob
fn greet(name: String) -> String {
let salutation = if name == "Ybixo" {
"Greetings"
} else {
"Hello"
}
"#{salutation}, #{name}!"
}
fn main() {
let greeting = greet("Ybixo")
print_line(greeting)
}
Use the Ybixo compiler to run the program and see its ouput:
$ obc run hello_world.ob
Greetings, Ybixo!
This update to our program introduces two important tools for programming in Ybixo.
Operations
name == "Ybixo"
Operations are expressions for common mathematical operations like addition, subtraction, and equality.
In our program we're using the equality comparison to check if the string bound to the variable name is "Ybixo".
Operations are similar to function calls, but use infix, prefix, or postfix notation.
Equality uses the == operator with infix notation, where the two operands appear on either side of the operator.
Conceptually, we can think of these two forms as equivalent:
name == "Ybixo"
equal(name, "Ybixo")
Comparison operations like equality evaluate to boolean values, i.e. either true or false.
An example of prefix notation is the unary operator ! which inverts a boolean value.
The following two lines both evaluate to true:
true
!false
An example of postfix notation is using square brackets to index a list. (Don't worry about what indexing a list means yet.)
list[0]
Conditional expressions
Conditional expressions allow our logic to branch and do different things based on a boolean expression, such as a comparison.
if name == "Ybixo" {
"Greetings"
} else {
"Hello"
}
This checks whether "Ybixo" is bound to the variable name.
If it is, the code inside the first set of curly braces is evaluated, producing "Greetings".
If it isn't, the code inside the second set of curly braces is evaluated, producing "Hello".
In our example program, we're assinging the conditional expression to a variable.
let salutation = if name == "Ybixo" {
"Greetings"
} else {
"Hello"
}
This illustrates that the entire conditional expression itself evaluates to a value: the last expression of the block that was chosen.
Assigning this value to a variable is not required.
We'll often seen conditionals used for their side effects.
For example, our entire program could be rewritten inside main like this:
fn main() {
let name = "Ybixo"
if name == "Ybixo" {
print_line("Hello, me!")
} else {
print_line("Hello, #{name}!")
}
}
Conditionals can also be chained as many times as needed if there are more than two logical branches:
if country == "Norway" {
"Greetings to the Norwegians!"
} else if country == "Egypt" {
"Greetings to the Egyptians!"
} else {
"Greetings to everyone!"
}
Finally, the else branch can be omitted if there is nothing to do when the condition doesn't match:
if name == "Ybixo" {
"It's me!"
}
Reading from standard input
While our greeting using conditionals could do different things based on who we were greeting, the same branch of code was executed every time because the input to the program never changed. Let's change the program to greet whomever is named by the user.
// File: examples/chapter_04_stdin/hello_world.ob
use std.io read_line
fn greet() -> String {
print("> ")
let name = read_line().unwrap_or("Ybixo").trim_end()
"Hello, #{name}!"
}
fn main() {
let greeting = greet()
print_line(greeting)
}
Run the program as usual:
$ obc run hello_world.ob
Notice that only a > appears but no greeting is output yet.
The program waits for input from you before it can proceed.
Type your name and hit return to let the program continue and greet you.
$ obc run hello_world.ob
> Al Dente
Hello, Al Dente!
A few new concepts are introduced here.
Imports
use path.to.module function_to_import
Functions can be imported from other modules.
A module is just a file containing Ybixo source code.
A function is imported with the keyword use, followed by the path to a module, followed by the name of the function to import.
Modules beginning with the path segment std are the standard library, a special collection of modules provided by Ybixo.
The print function
print is a function just like the print_line function we've been using, but it doesn't put a line break at the end of the given string.
We use this to indicate a prompt for user input, allowing the user to type on the same line that the > appears.
Reading from standard input
let input = read_line()
A line of text can be read from the standard input with the read_line function from the std.io module.
This allows our program to change behavior each time it's run depending on different inputs.
read_line blocks execution of the program until it receives a line from the standard input or until the standard input is closed.
Methods
value.method()
A method is a function that "belongs" to a type. Up until now, we had only called "free" functions, which look like this:
example()
But with a method, the function is called in the context of a specific value. This is done by by writing the value, followed by a dot, followed by the usual function call syntax. The method receives the value as its first argument. We can think of these two forms as equivalent:
function(value)
value.function()
The benefit of methods is that they allow us to chain together sequences of function calls in a natural way.
In our program, the function read_line returns a value, and we use the . operator to call the unwrap_or method on that value.
Similarly, the unwrap_or method returns a value, and again we use the . operator to call the trim_end method on that value.
trim_end is used to remove any extra whitespace from the end of a string.
unwrap_or is used for error handling, which we'll learn about next.
Fallible functions
read_line.unwrap_or("default")
Reading from the standard input involves an I/O operation which might fail, so we need to handle the case where we don't get a string back from the read_line call.
This is achieved by using the unwrap_or method to provide a default value to use in the case of error.
We can see this in action by running the program and pressing Ctrl + D (holding down the control key while pressing the D key), rather than typing a name.
This closes the standard input and will cause the program to return the default value we provided to unwrap_or.
unwrap_or uses a "functional" style to transform the potential error into this default value.
We don't deal with the value that read_line returns directly.
Next, we'll learn how to handle errors using an "imperative" style that will help us understand what read_line actually returns.
Handling errors with Result and match
In the last chapter, we saw this construct:
read_line().unwrap_or("Ybixo")
As explained, this calls the fallible function read_line and ensures that we always end up with a string by providing a default string to use in the case of error. But what is an error, exactly?
Here's the signature of read_line:
fn read_line() -> Result<String, String>
read_line returns a value of the type Result.
Result is a generic type, which means it can take different forms depending on the types it's used with.
It's also a compound type called an enum (short for enumeration), which is one of the two user-definable types in Ybixo.
We'll explore generics more in a future chapter.
For now, let's focus on what an enum is and why it's useful here.
Enums
An enum is a type that can have multiple variants.
Each variant is used to construct a value of the same type, but each can hold different data.
Ignoring generics for now, we can imagine read_line's return type as being defined like this:
enum Result {
Ok(String),
Err(String),
}
This means that a value of type Result has two possible variants: Ok, which holds a string, and Err, which also holds a string.
But if both variants hold a string, why is an enum necessary?
Why not just use a string?
The reason is because the variant itself implies different meaning for its contained string.
Ok's string is the input we receive from the standard input.
Err's string is a message with details about why the attempt to read a line from the standard input failed.
An enum is constructed by calling one of its variants like a function.
We write the name of the enum and the name of the variant separated by the . operator, followed by a value of the type it holds in parentheses, like this:
Result.Ok("It worked!")
Because Result is such a commonly used type in Ybixo, its variants are also available without the enum prefix, so we should write:
Ok("It worked!")
The match expression
Sometimes enums will provide methods like unwrap_or that handle specific use cases, but in the general case, we need a way to know which variant an enum value is in order to do anything useful with it.
The mechanism for this is the match expression:
// File: examples/chapter_05_errors/hello_world.ob
use std.io read_line
fn main() {
print("> ")
match read_line() {
Ok(line) -> print_line(line),
Err(reason) -> print_line("Error: #{reason}"),
}
}
This will have two possible outcomes:
- If
read_linereturnsResult.Okwith the line of user input we wanted, we print it. - If it returns
Result.Errwith an error message, we print the error message, prefixed with"Error: ".
Try running the above program.
When typing some text and pressing return, the typed text is printed right back.
When pressing Ctrl + D to close the standard input, the program prints:
Error: no input was received
Let's remove the specifics of this match example to understand the form of a match expression:
match expression {
pattern -> expression,
pattern -> expression,
}
match takes an expression to compare against, then a pair of curly braces.
Inside the curly braces are one or more match arms.
Each arm is a pattern, followed by an arrow, followed by an expression.
When a pattern matches, the corresponding expression on the right side of the arrow is evaluated.
Ybixo will walk through the patterns in the order they're defined, selecting the first pattern to match.
Patterns can contain variables, like line and reason in the read_line example above.
We can think of these as conditional variable assignments.
The strings inside each respective variant are bound to our variables, but only if the result matches the structure of the pattern.
In this way, patterns can be used to "unpack" enums, extracting the data held inside.
This unpacking is called destructuring.
The entire match expression ultimately evaluates to whichever match arm was selected, and so like conditional expressions, the value that match produces can be assigned to a variable or returned from a function:
fn read_line_wrapper() -> String {
match read_line {
Ok(line) -> line,
Err(reason) -> reason,
}
}
match matches exhaustively.
This means that the compiler requires that every possible case be covered.
For example, if we were to leave out the Err pattern in the example above, our Ybixo program would not compile, because we did not tell it what to do with a Result in the case that it's an Err:
match read_line {
Ok(line) -> line,
// Oops, we forgot to check for `Err`!
}
Warning: Non-exhaustive matches are not yet enforced at compile time. Failure to account for all possibilities might result in a runtime crash.
Iterating through collections
Our program currently greets whomever we specify on the command line. What if we want to be able to greet multiple people at once? We can achieve that with lists and loops.
// File: examples/chapter_06_loops/hello_world.ob
use std.io read_line
fn greet() -> List<String> {
print("> ")
let names = read_line().unwrap_or("Ybixo").trim_end().split(" ")
let greetings = []
for name in names {
greetings.push("Hello, #{name}!")
}
greetings
}
fn main() {
let greetings = greet()
for greeting in greetings {
print_line(greeting)
}
}
Run the program and enter multiple names separated by spaces when the program pauses for input:
$ obc run hello_world.ob
> Ybixo Rust Ruby
Hello, Ybixo!
Hello, Rust!
Hello, Ruby!
Let's take a look at the new ideas introduced here.
Lists
Lists are a core type that represent an ordered sequence of elements.
Every element in a list must be the same type, so we can't have a list containing integers and strings, for example.
A list type is written with the word List followed by the type of the element in the list in angle brackets.
Previously, our greet function returned a string, but now it returns a list of strings:
fn greet() -> List<String>
A list is created with a pair of square brackets.
In our program, we initialize the variable greetings as an empty list, which we will populate with elements later:
let greetings = []
Here's an example of creating a list with some initial elements:
let fruits = ["apple", "banana", "carrot"]
To access a specific item in a list, we use square brackets to perform an indexing operation:
fruits[0]
The number in the square brackets is a numeric offset from the beginning of the list, so 0 would be the first element of the list, 1 the second, and so on.
In our program, we're creating the list of names to iterate over using the split method on the String type:
read_line().unwrap_or("Ybixo").trim_end().split(" ")
Remember that read_line().unwrap_or("Ybixo").trim_end() returns a string.
If the string the user provided contains multiple words separated by some delimiter, like "Rust Ruby Ybixo", split will break these words apart into a list of multiple strings.
We pass " " to split to specify a single empty space as the delimiter between words.
If there are no spaces in the input, split will return a list containing only one element, the original string.
for loops
for name in names {
greetings.push("Hello, #{name}!")
}
for loops allow us to iterate through the elements in a list and run some code for each element.
These loops use the syntax for ITEM in ITEMS, where ITEMS is a list and ITEM is a variable name we choose.
A block of code in curly braces follows.
The block will be executed once for each item in the list.
Each time the block is executed, the next item in the list will be bound to the variable name we chose.
After the block has been executed for each item in the list, the loop ends and the program proceeds.
In our program, we iterate through all the names the user provided to us on the command line and add them to our greetings list by using the push method on the List type, which "pushes" the element onto the end of the list, after any elements that are already in it.
Back in our main function, we take the list of strings returned by our greet function and use another for loop to print each one back to the user:
for greeting in greetings {
print_line(greeting)
}
What if we want to skip elements in a list or stop iterating a list before we reach the end?
That can be achieved with the continue and break keywords, respectively:
// File: examples/chapter_06_loops/continue_break.ob
fn main() {
let fruits = ["apple", "banana", "carrot"]
for fruit in fruits {
if fruit == "apple" {
continue
}
print_line(fruit)
if fruit == "banana" {
break
}
}
}
continue means "immediately proceed to the next iteration of the loop".
break means "immediately stop iterating the loop".
In this example, when the loop iterates on "apple", we continue to the next iteration rather than printing anything.
When the loop iterates on "banana", we print it and then break out of the loop before proceeding to "carrot".
As a result, "banana" is the only one of the three fruits that is printed.
while loops
for loops are the right choice when we need to iterate through a list, but there are times when we might want to execute a block of code multiple times without a list.
There's a more general kind of loop called a while loop that we would use in this case.
// File: examples/chapter_06_loops/while_loop.ob
fn main() {
let i = 1
while i <= 10 {
print_line(i)
i = i + 1
}
}
A while loop takes a conditional expression and a block of code.
It starts by evaluating the condition.
If it's true, the code in the block is executed and then the condition is evaluated again.
If it's false, the loop ends and the program continues.
In this example, we print the integers from 1 to 10.
We start with a variable i bound to 1, then check that i is less than or equal to 10.
If it is, we print it and increment the value of i by 1.
The condition will then run again against the new value of i.
This continues until i reaches 11, at which point our loop ends without printing the number.
Indefinite loops
Notice that the while loop always evaluates the condition before the block executes, even the first time.
If we want to always execute the code in the block at least once without checking a condition, we can use loop and break out of it explicitly.
The same logic as our while loop example can be expressed this way:
// File: examples/chapter_06_loops/indefinite_loop.ob
fn main() {
let i = 1
loop {
print_line(i)
i = i + 1
if i > 10 {
break
}
}
}
Be careful with terminating conditions when using loops.
If the condition never evaluates to true, the loop will be infinite and the program will hang!
Associating values with hash maps
Lists are useful for representing ordered collections of values, but aren't the right tool when we want to associate values with one another. For example, if we had a shopping list, where each type of food was associated with the quantity of that food to buy, it would be awkward to represent as a list. The right tool is a hash map.
let shopping_list = ["apple": 3, "banana": 1, "carrot": 2]
As we can see, a hash map looks very much like a list, but instead of each item being a single expression, each item is two expressions separated by a colon. The expression on the left is called the key and the expression on the right is called the value. For this reason, we'll sometimes hear hash maps referred to as key-value pairs. Hash maps may also be referred to as dictionaries, since they are structured like a dictionary, which has words associated with definitions.
The reason it's called a hash map is because it uses a hash function on each key to determine how to uniquely identify a key-value pair.
Any type can be a value in a key-value pair, but only hashable types can be used as keys.
All the basic data types, Boolean, Integer, Float, and String are hashable.
We'll learn how to make our own types hashable in a future chapter.
Iterating through key-value pairs
for (food, quantity) in shopping_list {
print_line("Number of #{food} to buy: #{quantity}")
}
Just like a list, we use a for loop to iterate through the key-value pairs in a hash map.
The only difference is the variable we use to bind each pair is compound.
Instead of a single variable name like item, we have two variables in parenthesis, separated by a comma: (key, value).
The reason we're able to do this is that the expression that comes after for is not just a variable name, but a pattern.
Remember that patterns allow us to destructure compound values into their constituent parts.
Don't worry about that for now.
We'll explore patterns further in a future chapter.
For now, just remember that when using a for loop with a hash map, we must bind both the key and the value.
Inserting values
To insert a new value into a hash map, use the indexing operator plus an assignment:
shopping_list["dragonfruit"] = 1
If there was already a value associated with the key, it will be replaced with the one we're assigning.
Retrieving a value by key
To access a specific value in a hash map, use the indexing operator, just as with lists, but with a key instead of an integer offset:
shopping_list["carrot"]
What does the above expression evaluate to?
We might have guessed 2, based on the initial value of shopping_list at the start of this chapter.
But it's a little more complicated than that.
We'll find out why in the next chapter.
Accounting for missing data with Option
Many programming languages have a construct that represents the absence of data, often called null or nil.
This is used as a placeholder for things that contain data under certain conditions, but don't always.
Ybixo doesn't have a dedicated type for this.
Instead, it uses an enum named Option to represent a value that might be present or might not.
Let's look at the definition of the type.
enum Option<t> {
Some(t),
None,
}
We learned about enums previously when discussing the Result type.
Recall that Result is a similar enum that represents either the success or failure of a computation.
In Result's case, both enum variants, Ok and Err, contain a value that gives more information: either the value that was produced by a successful computation, or details about an error that occurred.
Option is similar in that it has two variants, but the second case, None, does not hold any additional data, because the variant represents a lack of data entirely.
We still haven't talked in detail about generics, but for now, note that Option can hold any type of value inside it.
The <t> is used to indicate this.
Here are some examples of constructing Options:
let some_string = Option.Some("Ybixo")
let some_integer = Option.Some(123)
let none = Option.None
Like Result, because Option is so widely used, its variants are available to use directly, so we should write this instead:
let some_string = Some("Ybixo")
let some_integer = Some(123)
let none = None
In the last chapter, we asked what indexing into a hash map evaluated to:
let shopping_list = ["apple": 3, "banana": 1, "carrot": 2]
shopping_list["carrot"]
The answer is Some(2), a value of type Option<Integer>.
We might think it should just be Integer, since we can clearly see that we associated "carrot" with 2 when creating the hash map on the previous line.
But if that was the case, what would the indexing operation return for a key that had no value associated with it?
shopping_list["beans"] // ???
A function always returns the same type, so we can't return two different types under two different circumstances.
But this is exactly what Option is for:
Telling us when a value of a certain type might be there and might not.
Using an enum, we're able to return one of two variants which are both values of the same type.
With Option we can safely handle hash map indexing by matching on an optional value:
fn food_count(shopping_list: HashMap<String, Integer>, food: String) {
match shopping_list[food] {
Some(quantity) => print_line("Buy #{quantity} #{food}."),
None => print_line("Do not buy any #{food}"),
}
}
This function takes a hash map of strings to integers, like our shopping list, and a string to use to index into the hash map.
We perform the index and then match on the returned Option.
If there was a value associated with key, we print both the key and the value.
If there was no value associated with the key, we print a fallback message with only the key.
Remember that match expressions must cover all cases exhaustively, so if we leave out the None match arm, our program won't compile.
This enforcement reduces bugs in our programs because we can't simply forget to check for the case of missing data.
Warning: Non-exhaustive matches are not yet enforced at compile time. Failure to account for all possibilities might result in a runtime crash.
A note about lists
Recall that when we learned about lists, we learned that indexing into a list uses the same square bracket syntax as indexing into a hash map, only with an integer offset rather than a key name:
let fruits = ["apple", "banana", "carrot"]
fruits[0] // "apple"
It's worth noting that currently, this operation does not return an Option, as it does for hash maps, even though it's the same idea, conceptually.
Instead, accessing an array index that is out of bounds (e.g. fruits[3] above) will cause an error when the program actually runs.
This is primarily for convenience, since manual bounds checking on every list index would add a lot of extra code when working with nested lists.
However, this is likely to change to a safer construct in a future revision of Ybixo.
Sharing behavior with closures
Program logic in Ybixo lives in functions, but there's another kind of function we haven't seen yet. It's called a closure, or alternatively an anonymous function. These functions are anonymous, because unlike the ones we've seen so far, they don't have names. They're called closures because they "close over" their environment, which we'll see in a moment.
Closures are values, just like string, integers, booleans, etc. They can be assigned to variables or passed as arguments to other functions. Closures are written and used like this:
// File: examples/chapter_09_closures/closure.ob
fn main() {
let closure = fn () {
print_line("Hello from a closure!")
}
// Prints "Hello from a closure!"
closure()
}
As we can see, closures really are just functions without names.
This closure has no parameters and returns () (i.e. the default return value for functions when none is specified).
Instead of being defined at the top level of the file, it's defined inside the named function main, and it's assigned to a local variable.
Calling the closure is done by writing whatever local variable name it's bound to, followed by parenthesis, just like a regular function.
We might wonder why closures are useful if they are defined and used more or less like regular functions.
There are two main reasons.
Warning: Calling a closure like a function doesn't work yet. Instead, you must invoke the
callmethod on the closure, i.e.closure.call(). The version of the above program found in the examples directory uses the currently working form.
Closures as function parameters
Since closures are values, they can be passed as arguments to functions.
A great example is the map method on the Result type.
Let's say we want to read in a line of text from the standard input and transform the string created into the number of characters that were in the string.
// File: examples/chapter_09_closures/map.ob
use std.io read_line
fn main() {
let length = read_line().map(fn (s) {
s.length()
}).unwrap_or(0)
print_line("The input length was #{length}.")
}
Recall that read_line returns a Result that contains either a string or an error, depending on whether the I/O succeeded or failed.
The map method takes a closure, which itself takes the result's Ok type as an argument and must return a value.
In this case, the Ok type is String.
We pass map a closure accepting one argument, s, and then return s.length() from the closure.
Finally, we use the unwrap_or method like we did previously to specify a default value if no input was read.
Previously, the default value was a string, matching the original result's Ok type.
But in this case, the closure we provide to map transforms the String into an Integer, so unwrap_or must provide a default integer.
Internally, map checks which variant the result is.
If it's Ok and contains a string, it will call our closure and pass it that string as an argument.
It will then return a new result with the return value of our closure as its Ok type.
If the original result was the Err variant, it will simply return itself and our closure won't be called.
This allows us to specify a transformation for the Ok value without knowing in advance whether or not read_line succeeded.
We might also notice that the type of the s parameter of the closure is not specified as it would be with a function.
Because closures are usually used "inline" like this, and there's context from the surrounding code about how they're being used, it's okay to leave the variable types out.
They will be figured out by the compiler.
If we want to, we can specify the type of s using the same syntax as a regular function, s: String.
Environment capture
The other reason closures are useful is that they capture their environment. What this means is that they "remember" local variables that were in scope at the location they were defined, even if they are called in a different location. To demonstrate:
// File: examples/chapter_09_closures/capture.ob
fn make_greeting() -> Fn() -> String {
let name = "Ybixo"
fn () -> String {
"Hello, #{name}!"
}
}
fn main() {
let greeting = make_greeting()
// Prints "Hello, Ybixo!"
print_line(greeting())
}
The key insight here is that the body of the closure is not executed until it is called on the last line of main.
make_greeting is a function that returns another function, and this is reflected in its signature with the multiple return types chained with ->.
Specifically, make_greeting returns the type Fn() -> String, which is how we write the type of a closure that takes no arguments and returns a string.
We store the closure returned by make_greeting in a local variable just called greeting.
When we call the closure on the last line, it can still access name, a local variable that was defined at the point that the closure was defined, back in the make_greeting function, even though that function has already returned by the time we execute the closure.
This property of closures can be very handy when passing them as arguments to functions.
Warning: Calling a closure like a function doesn't work yet. Instead, you must invoke the
callmethod on the closure, i.e.closure.call(). The version of the above program found in the examples directory uses the currently working form.
Modeling data with structs
Our programs so far have dealt entirely with types native to Ybixo.
These have mostly been scalar types like String and Boolean, but we've also seen compound types like List and HashMap.
If we want to create our own types to model data in our programs, there are two options available.
The first are structs, which is short for structures.
We might also hear the same concept described as records, objects, or product types in other languages.
A struct is a single type that is made up of multiple pieces of data.
Each of these constituent pieces has a name associated with it. Here's an example:
struct Person {
name: String,
age: Integer,
}
A struct is defined with the struct keyword, followed by a type name beginning with a capital letter, and then a comma-separated list of fields inside curly braces.
A field is a name beginning with a lower case letter, a colon, and the type of the field.
Constructing a struct inside a function uses very similar syntax, but specifies the values instead of the types:
fn alice() -> Person {
Person {
name: "Alice",
age: 42,
}
}
Fields can be accessed with the . operator for reading and writing:
let person = Person {
name: "Alice",
age: 42,
}
// Prints "The person's name is Alice."
print_line("The person's name is #{person.name}.")
person.name = "Ainsley"
// Prints "The person has changed their name to Ainsley."
print_line("The person has changed their name to #{person.name}.")
Instance methods
There are two benefits to using structs rather than using individual variables to hold each piece of data.
The first is that it's easier to think about larger concepts like Person if all the data is combined, matching the way we think about it.
The second is that it allows us to attach behavior to the data in the form of methods.
Let's write some code similar to the previous example, but with a Person knowing how to print a sentence with its own name.
// File: examples/chapter_10_structs/instance_methods.ob
struct Person {
name: String,
age: Integer,
fn print_name(self) {
print_line("My name is #{self.name}.")
}
}
fn main() {
let person = Person {
name: "Alice",
age: 42,
}
// Prints "My name is Alice."
person.print_name()
}
A method is created by writing a function inside the definition of the struct, after all of the fields.
The print_name method has one parameter named self.
Notice that unlike usual function parameters, self is not followed by a colon and a type.
It's a special parameter that refers to an instance of the method's type.
Inside the method, we use self to access properties of the Person value associated with a particular method call.
The self parameter always comes first in the parameter list.
Recall that when we first learned about methods, we learned that the following two forms are equivalent:
function(value)
value.function()
That's exactly what's happening when we call person.print_name() at the end of the main function.
We don't pass any arguments to print_name, but inside the body of the print_name method, person is bound to self.
Methods that take a self parameter are called instance methods because they operate on an instance of a type, i.e. a value.
Static methods
Methods don't have to have a self parameter.
If they don't have one, they are considered static methods.
Unlike instance methods, the output of a static method is only determined by the arguments given to it directly.
As such, it's more like a regular function than a method.
The reason to use a static method rather than a free function (one not associated with a type) is to make it clear that the function's behavior has something to do with that type.
We'll commonly see static functions used for constructors. These are functions that initialize a new value of the type:
// File: examples/chapter_10_structs/static_methods.ob
struct ShoppingList {
map: HashMap<String, Integer>,
fn new() -> Self {
Self {
map: ["apple": 3, "banana": 1, "carrot": 2],
}
}
fn print_contents(self) {
for (food, quantity) in self.map {
print_line("Quantity of #{food} to buy: #{quantity}")
}
}
}
fn main() {
let shopping_list = ShoppingList.new()
shopping_list.print_contents()
}
The static method new constructs a new ShoppingList with a few predetermined food items to buy.
Using this constructor function, we can easily create a new ShoppingList without having to know anything about ShoppingList's fields.
Both instance and static methods are called using the . operator.
The difference is that instance methods are called via values while static methods are called via the type and are not associated with any particular value.
We might have noticed that the new function returns the type Self.
This is an alias that always refers to whatever type the associated function or method belongs to.
In this case, writing Self is the same as writing ShoppingList.
It's a nice shortcut that we can use to avoid writing the full name of the type over and over.
It also means that if we decide to rename the type, we only have to change its name in one place within its definition.
In Ybixo, static function names don't have any special meaning, so we don't have to name constructors anything specific like new, as we do in other programming languages.
However, new is a coventional name that most programmers will find unsurprising, so it's a good idea to use for a type's main constructor.
Warning: The
Selftype alias is not yet available. The version of the above program found in the examples directory usesShoppingListto account for this.
Tuple structs
There are two other kinds of structs we can create.
The first is called a tuple struct. The difference from the main form of struct is that the fields are not named. They are defined like this:
struct Name(String, String)
And constructed like this:
let name = Name("Alice", "Henderson")
Tuples are a core data type we haven't talked about yet. They are like lists, in that they are a sequence of ordered values, but they have a fixed length (whereas lists can change size) and they need not all be the same type. The following are all examples of tuples:
// Type: (String, String)
("Alice", "Henderson")
// Type: (Integer, String, String, String)
(123, "Main Street", "Anytown", "New Mexico")
// Type: () - an empty tuple, called a "unit"
()
// Type: (String)
// The comma is needed to distinguish from a parenthesized expression
("Ybixo",)
These "raw" tuple types do not have names. A tuple struct is simply a way to represent such a type with a specific name, and to give it methods.
Just like structs with named fields, tuple structs can have both instance methods and static methods:
struct Name(String, String) {
fn new(first_name: String, last_name: String) -> Self {
Self(first_name, last_name)
}
fn full_name(self) -> String {
"#{self.0} #{self.1}"
}
}
This also illustrates how to access the fields of a tuple struct:
Use the . operator followed by a number, referring to the offset from the start of the tuple's values, just like a list.
The same syntax is used for regular tuples.
Tuple structs can be useful to "wrap" another type and give it new functionality, a technique called a newtype:
struct ExclamationString(String) {
fn exclaim(self) -> String {
"#{self.0}!"
}
}
They're also convenient when the meaning of the fields are obvious and writing out names for them wouldn't provide much value.
Warning: The
Selftype alias is not yet available as shown above.
Unit structs
The other kind of struct is the unit struct, which holds no data at all. They are defined like this:
struct Empty
They are constructed simply using the the type name:
let empty = Empty
Like the other kinds of structs, we can define methods on unit structs. Since they have no data to work with, the benefit of doing this is mostly for organizing functions that relate to the same concept:
struct First {
fn first_letter() -> String {
"a"
}
fn first_positive_integer -> Integer {
1
}
}
Modeling data with enums
Enums (enumerations) are the second option for creating our own types in Ybixo.
We've already had some experience with enums with the Result and Option types.
An enum is a single type that can have multiple variants.
Each variant can contain different data, but each variant is considered the same type.
We might also hear the same concept described as a sum type in other languages.
enum TrafficLight {
Red,
Yellow,
Green,
}
An enum is defined with the enum keyword, followed by a type name beginning with a capital letter, and then a comma-separated list of variant names, each beginning with a capital letter.
Constructing an enum inside a function looks like accessing a field on a struct:
fn main() {
let red = TrafficLight.Red
}
TrafficLight is an example of an enum with unit variants.
This is the same idea as the unit structs that we learned about in the last chapter:
Types that hold no data but can have methods.
Like structs, enum variants can also have named fields:
enum Book {
Unread,
LeftOff {
page: Integer,
},
Read,
}
In this example, the LeftOff variant has a named field, while Unread and Read are unit variants with no fields.
Since structs are usually used in the form with named fields, an enum variant with named fields is called a struct variant.
For cases with data but where named fields aren't desired, we can also create tuple variants, just like tuple structs:
enum Quadrilateral {
Rectangle(Integer, Integer),
Square(Integer),
}
In this example, the Rectangle variant is a tuple with two integers representing the height and width of the quadrilateral, while the Square variant is a tuple with an Integer representing the length of all four sides.
Note that a trailing comma in the single-element tuple is not necessary.
That's only needed when creating a tuple value.
Methods
Like structs, enums can have instance methods and static methods:
// File: examples/chapter_11_enums/vehicle.ob
enum Vehicle {
Car,
Truck,
fn car() -> Self {
Self.Car
}
fn truck() -> Self {
Self.Truck
}
fn start(self) -> String {
match self {
Self.Car -> "Started the car.",
Self.Truck -> "Started the truck.",
}
}
}
fn main() {
let car = Vehicle.Car
print_line(car.start())
}
These work in exactly the same way as structs, except that each method needs to do some extra work to determine which variant it's being called on.
Remember that Self is an alias for the type the method belongs to.
Warning: The
Selftype alias is not yet available. The version of the above program found in the examples directory usesVehicleinstead to account for this.
Pattern matching
We introduced the match expression in the chapter about Result, but just as a refresher:
When we have an enum value, we use match to determine which variant it is, as above in the Vehicle.start method.
If a variant contains data, match is also used to extract that data into local variables.
This works for both forms of variants that hold data:
// File: examples/chapter_11_enums/pattern_matching.ob
enum E {
StructVariant {
a: String,
b: Integer,
},
TupleVariant(String),
UnitVariant,
}
fn main() {
let e = [
E.StructVariant {
a: "a",
b: 1,
},
E.TupleVariant("t"),
E.UnitVariant,
].sample()
match e {
E.StructVariant { a, b } -> print_line("Struct variant with data #{a} and #{b}"),
E.TupleVariant(x) -> print_line("Tuple variant with data #{x}"),
E.UnitVariant -> print_line("Unit variant with no data"),
}
}
In this example, we create a list containing three enums, one for each of the variants of E.
We then use the sample method on List to pick a random element from the list.
Each time the program is run, e might be a different variant.
The match expression is used to handle every case of what e might be.
The named fields of the struct variant are matched and destructured using curly braces, matching the syntax for struct definition and initialization.
The same is true for the tuple variant, which is matched and destructured using parens.
Finally, the unit variant, which doesn't hold any data, is just matched by its name.
You can see in each match arm that any variables bound by that arm are used in the expression after the arm's arrow.
These types of patterns are actually not unique to enum variants. They work exactly the same way for the three forms of structs. That's because enums are really just a way of combining multiple structs into a single type.
Organizing code with modules
For simple programs like the ones we've written so far, there's no trouble fitting everything in one file, but for larger programs, it's useful to split code up across multiple files to help with readability and organization.
In Ybixo, a module is a single file containing Ybixo source code.
Modules define types, functions, and a few more things we haven't learned about yet.
Each of these things is called an item.
An item can be imported from one module into another with the use keyword.
This is how modules share code with each other.
Importing items from another module looks like this:
use database.tables Person, load_people_from_json
The syntax is the keyword use, followed by the path to the module (as you would see in the file system, except with the slashes replaced with dots and the ".ob" suffix omitted), and a comma-separated list of items defined in that module to be imported into the current one.
You can have as many imports in a module as you need.
The path of a module is written relative to the main module of the program (the one you provide to the obc build command as the INPUT argument).
Modules beginning with the path std are reserved for Ybixo's standard library.
There can't be two items in a module with the same name, so if there's a name conflict, imports must be renamed with the -> operator:
use library_a Name -> AName
use library_b Name -> BName
There's a special "module" called self that allows us to create aliases to items defined in the current module.
This is handy for using enum variants without naming the enum:
use self Vehicle.Car
enum Vehicle {
Car,
}
fn main() {
// This works as it always does
let car = Vehicle.Car
// Because of the `self` import, this also works
let car = Car
}
In addition to reducing file size and grouping code for related functionality, modules also allow for hiding implementation details from other modules.
Encapsulation and visibility
By default, items are private to the module they are defined in, meaning that they cannot be imported into other modules.
Struct and enum fields are also private by default, meaning they cannot be accessed directly with the . operator from other modules.
Items and fields can be made public using the pub keyword, allowing us to selectively expose functionality to serve as the module's "public interface" while keeping any internal bookkeeping or support code hidden.
This idea is called encapsulation and is a commonly used technique across many programming languages.
Let's look at an example of how this can be useful to protect the data in our programs. Back in the chapter on structs, we had a program that looked like this:
struct Person {
name: String,
age: Integer,
}
fn main() {
let person = Person {
name: "Alice",
age: 42,
}
print_line!("The person's name is #{person.name}.")
person.name = "Ainsley"
print_line!("The person has changed their name to #{person.name}.")
}
In this program, the main function has direct access to Person's fields, because Person is defined in the same module.
We create a Person and then change its name afterwards, simply by assigning a new value to its name field.
We reach inside the struct and change its internal state, and there is nothing the struct can do to stop us.
When our data is very simple this might not be a problem and might be the most convenient way to work. But if we want to maintain the integrity of the data in the struct, we might want to protect it. Consider the following example:
// File: examples/chapter_12_modules/exposed_shopping_list.ob
struct ShoppingList {
map: HashMap<String, Integer>,
}
fn main() {
let shopping_list = ShoppingList {
map: HashMap.new(),
}
shopping_list.map["apple"] = -1
print_line("Number of apples to buy: #{shopping_list.map["apple"]}")
}
In the chapter on hash maps, we used a hash map to represent a shopping list.
In this example, we use a dedicated struct called ShoppingList to represent it.
Internally, our struct uses the same hash map as before to keep track of the actual data.
In our main function, we create a new shopping list, and then set the quantity of apples to buy to -1.
But that doesn't make any sense.
How can you buy a negative number of apples?
Our struct should protect access to its internal data to make sure the user doesn't change it to something that doesn't make sense.
If we define the struct in its own module, we can use encapsulation to prevent this problem:
// File: examples/chapter_12_modules/shopping_list.ob
pub struct ShoppingList {
map: HashMap<String, Integer>,
pub fn new() -> Self {
Self {
map: HashMap.new(),
}
}
pub fn add_food(self, food: String, quantity: Integer) -> Result<(), String> {
if quantity < 1 {
return Err("Quantity must be at least 1.")
}
self.map[food] = quantity
Ok(())
}
}
// File: examples/chapter_12_modules/main.ob
use shopping_list ShoppingList
fn main() {
let shopping_list = ShoppingList.new()
match shopping_list.add_food("apple", -1) {
Ok(_) -> print_line("Added apples to our shopping list."),
Err(error) -> print_line("Couldn't add apples to our shopping list: #{error}"),
}
}
A few things have changed here.
Visibility
ShopingList and the code that uses ShoppingList are now defined in two separate modules.
ShoppingList is in its own module, in a file called shopping_list.ob, while our main function is in a separate module, in a file called main.ob.
In the main module, we import the struct with the use keyword.
Note that the definition of ShoppingList in the shopping_list module is now prefixed with pub.
This is required for it to be imported into the main module.
Once we import ShoppingList, it's available for use in the main module, but only via its public interface.
Now that we're using the struct in a different module from where it was defined, we can no longer access its fields directly by default.
We can only access them if they have public visibility, indicated with the keyword pub.
In this case, the map field in the struct is not prefixed with pub, so it's private.
In order to read or write data from the struct, we need to use its public methods.
Because we can no longer access map directly from outside the struct, you'll notice that the syntax for creating the shopping list at the beginning of the main function has changed:
let shopping_list = ShoppingList.new()
Instead of using the struct initialization syntax to specify the initial value for the struct's fields, we call the constructor function new, which returns a new ShoppingList with default values.
Of course, it was necessary to define this constructor function, and we've done so inside the struct's body back in the shopping_list module, making it public with pub.
Since ShoppingList can access its own fields inside its own functions, we're able to specify an initial value for map inside new.
The add_food method
pub fn add_food(self, food: String, quantity: Integer) -> Result<(), String> {
if quantity < 1 {
return Err("Quantity must be at least 1.")
}
self.map[food] = quantity
Ok(())
}
Setting the quantity of a certain food to buy is now achieved by calling the add_food method rather than modifying the internal map field directly.
The add_food method checks the quantity it's given to make sure it's a positive value before changing the state in map.
If it's not, it returns an error back to the calling code in the main function.
But if the quantity supplied was good, add_food returns (), the empty tuple called unit that we learned about previously.
There's no meaningful value to return for the success case, but we have to return something inside the Result.Ok variant to signal to the caller that the operation succeeded.
This means that back in main, we must check to see whether or not our addition succeeded:
match shopping_list.add_food("apple", -1) {
Ok(_) -> print_line("Added apples to our shopping list."),
Err(error) -> print_line("Couldn't add apples to our shopping list: #{error}"),
}
When we match on the result, we use the pattern Ok(_).
The underscore matches any value but does not bind it to a variable.
We use this because we aren't going to be doing anything with the unit inside the result—we just want to know when the add_food call was successful.
Just like new, add_food is marked with pub so we can use it from main.ob.
Of course, not all methods need to be marked with pub.
It's often useful to have methods with private visibility, which can only be called by code in the defining module.
This allows us to be selective in how we expose our struct's behavior to outside code.
Private methods can be used for implementation details that outside code doesn't need to know about.
When writing our own types, it's a good idea to use the default, private visibility when possible.
Only make fields and methods public with the pub keyword when you need to.
A field should be private if it could be made invalid as it could with our shopping list.
Warning: Currently, importing from a module does not automatically build that module. Each dependent module must be built individually with
obc build.
Warning: The
Selftype alias is not yet available. The version of the above program found in the examples directory usesShoppingListinstead to account for this.
Warning: Item and field visibility is not yet enforced, so code that should be private is currently accessible from other modules.
Warning: Currently, modules cannot be in nested directories. Outside of the automatically generated
stdmodules, all user modules must live in the same directory.
Reusing code with generics
When we learned about the Result and Option types, we mentioned that these were generic types, but didn't fully explain what that means.
Now it's time to explore this feature in depth!
Generic functions
We know that functions can have parameters—values they take as input that determine what they ultimately return.
fn identity(value: Integer) -> Integer {
value
}
This simple identity function has a parameter for an integer, which it then returns right back to the caller.
If we wanted to use strings, we'd have to write a new function with the types changed:
fn string_identity(value: String) -> String {
value
}
The key insight here is that the type of the parameter is not relevant to the logic inside the body of these functions. No matter what the parameter is, the functions simply return it.
By making this function generic, we'll have a single function definition that works for any type:
// File: examples/chapter_13_generics/identity.ob
fn identity(value: t) -> t {
value
}
fn main() {
let one = identity(1)
let ybixo = identity("Ybixo")
print_line(one)
print_line(ybixo)
}
The function signature looks almost the same as before, but we're now saying that the parameter value is a type called t.
But t is not a concrete type like Integer or String.
It's a type parameter, in the same way that value is a value parameter.
Type names in Ybixo must start with a capital letter.
A name starting with a lowercase letter in a context where a type is expected indicates that the name refers to a type parameter rather than a concrete type.
Other than the requirement that it start with a lowercase letter, we can name a type parameter anything we want.
t (standing for "type") is a common type parameter name for situations where we know nothing at all about what the concrete type might be, but longer, more descriptive type parameter names are often easier to read and understand.
In our main function, we call identity twice, first with an integer, and second with a string.
We don't declare explicitly what concrete type we want to use when calling identity.
The Ybixo compiler uses a technique called type inference to figure it out based on the types of the arguments.
Sometimes there is not enough information for the compiler to figure out the concrete type.
We'll see an example of that in a moment.
Generic types
Types we define can also be generic. Here's a tuple struct that can hold any type inside it:
// File: examples/chapter_13_generics/container.ob
struct Container<t>(t)
fn main() {
let contained_integer = Container(1)
let contained_string = Container("Ybixo")
print_line(contained_integer)
print_line(contained_string)
}
The t is mentioned twice in the definition of the struct.
The first one, in angle brackets, is part of the name of the type, Container<t>.
The second one is the normal tuple struct syntax we've seen before, declaring t as the one and only unnamed field stored in the struct.
We've already seen examples of enums that can hold any type with Result and Option:
enum Result<t, e> {
Ok(t),
Err(e),
}
enum Option<t> {
Some(t),
None,
}
Here's an example that shows they are generic over the type they contain:
// File: examples/chapter_13_generics/no_type_annotation.ob
fn main() {
let ok_integer = Ok(1)
let ok_string = Ok("Ybixo")
let err_integer = Err(-1)
let err_string = Err("Uh oh!")
let some_integer = Some(1)
let some_string = Some("Ybixo")
let none_integer = None
let none_string = None
}
If we try to compile the above program, we'll get errors saying that the types of none_integer and none_string can't be determined.
None is the variant of Option where data is absent, but without context, it doesn't tell us what kind of data is absent.
In cases like this, we need to give the compiler a hint by adding type annotations to our variables:
// File: examples/chapter_13_generics/type_annotation.ob
fn main() {
let none_integer: Option<Integer> = None
let none_string: Option<String> = None
}
Type annotations are type names that come after the variable name, with the two separated by a colon.
The <Integer> syntax tells the compiler what the concrete type of the type parameter t is for this particular None value.
Warning: The type checker is not implemented yet, so the
no_type_annotation.obmodule will actually compile just fine.
What can we do with a t?
Generics are a powerful tool for code reuse.
With a single type parameter, we can do a lot.
A great example is the unwrap_or method on Result that we looked at way back in the chapter on reading from standard input.
Remember this?
let name = read_line().unwrap_or("Ybixo")
Now that we understand generics, let's see how unwrap_or is defined:
pub enum Result<t, e> {
Ok(t),
Err(e),
pub fn unwrap_or(self, default: t) -> t {
match self {
Ok(t) -> t,
Err(_) -> default,
}
}
}
unwrap_or takes a default value, which must be the same type as the result's Ok variant.
It checks which variant this particular result is using a match expression.
If it's the Ok variant, it returns the t inside.
If it's the Err variant, it returns the default t given as an argument.
The _ in the Err match arm is a wildcard indicating that we don't care what the value inside the Err is.
unwrap_or is able to apply this same logic to any type t without having any idea what it actually is.
There are limits to what we can do with a completely unknown t, however.
Try compiling this example:
// File: examples/chapter_13_generics/add.ob
fn add(a: t, b: t) -> t {
a + b
}
We might expect this to work fine, since all it does is add two ts together, but we'll get a compiler error saying that t cannot be added to t.
Why not?
How can we add two ts together?
We'll find out in the next chapter.
Warning: The type checker is not implemented yet, so the
add.obmodule will actually compile just fine.
Defining common behavior with traits
In the last chapter, we tried to define a generic function that adds two values together:
// File: examples/chapter_13_generics/add.ob
fn add(a: t, b: t) -> t {
a + b
}
This doesn't work because when we have a generic type like t that could be any type, we can't assume anything about what it can do.
We can add numbers together with + but that isn't true for all types.
For example, what would it mean to add two boolean values together?
If we want to use specific operators or methods with our generic types, we need to constrain the type parameters with traits.
Traits
A trait is a set of functions that multiple types can have in common. In other languages you might hear the same concept described as a type class. Traits are also similar to the concept of interfaces in other languages. It's a way to define common behavior across types.
Here's an example of a trait from the standard library:
trait ToString {
fn to_string(self) -> String
}
A trait is defined with the keyword trait followed by a name in capital letters (like a type name, but generally phrased as a verb) and then function signatures inside curly braces.
Note that the function to_string has no body.
That's because it's up to the types that implement the trait to define how the function behaves for that type.
Let's implement the ToString trait for the Person type we've used previously:
// File: examples/chapter_14_traits/person_to_string.ob
struct Person {
name: String,
age: Integer,
}
impl ToString for Person {
fn to_string(self) -> String {
"#{self.name} is #{self.age} years old."
}
}
fn main() {
let person = Person {
name: "Alice",
age: 42,
}
// Prints "Alice is 42 years old."
print_line(person)
}
Person is the same as we've seen before: a struct with a string name and an integer age.
We implement the ToString trait for Person with the impl keyword followed by the trait name, the for keyword, and then the type we're implementing the trait for, Person.
Inside the curly braces, we define each function from the trait, but this time we provide a function body that determines how to create a String given a Person.
In the main function, we create a person and then pass it to the print_line function.
Notice that we don't actually call the to_string method anywhere.
Why not?
What was the point of implementing the trait if we don't use it?
To understand that, we need to look at the signature of the print_line function:
fn print_line(s: s) where s: ToString
There's also something new after the return type:
There's some new syntax we haven't seen before after the parameters.
The where keyword followed by some type shenanigans.
This is called a "where clause" and it lists constraints on the types in the signature.
We know that a type starting with a lowercase letter is actually a type parameter, and that the parameter s: s means that the function accepts a value of some type s that will be bound to the variable s.
Don't be confused about the repetition of the letter here.
It's just how this particular function is defined.
The parameter could just as easily be something like string: t or any other combination of variable names and it would work the same way.
But what is the meaning of the s: ToString in the where clause?
This means that the type parameter s cannot be a value of any type.
It must be a value of a type that implements the ToString trait.
We say that s is constrained to a type that implements ToString or that "s must be ToString."
Because print_line doesn't know anything about s other than that it implements ToString, it can't do much with it other than call the to_string method.
But that's okay, because that's all print_line needs.
In its implementation, it calls to_string on the given argument, and prints the resulting string to the standard output.
Warning: All user-defined types can be printed with
print_lineregardless of whether or not they implementToString. ImplementingToStringwill override the default formatting.
Operators as traits
Let's finally bring this back around to our add function:
// File: examples/chapter_13_generics/add.ob
fn add(a: t, b: t) -> t {
a + b
}
We know now that we can't use methods unless we know that the types involved implement those methods.
We can't add two ts together if we don't know that they support addition.
In Ybixo, operators are implemented as traits.
If we specify that t must be Add, our function will work as we orignally expect:
use std.ops Add
fn add(a: t, b: t) -> t where t: Add {
a + b
}
Now add will work with any type, as long as that type is Add.
This is the basis for operator overloading in Ybixo, a form of ad-hoc polymorphism that allows us to use the built-in operators like + with our own types.
Implementing traits
Let's use this knowledge to implement Add for the ShoppingList type we made previously.
To do that, we'll need to look at how Add is defined:
pub trait Add<rhs> where rhs = Self {
type Output
fn add(self, other: rhs) -> Self.Output
}
This uses more syntax we haven't seen before.
The where clause specifies a type parameter named rhs (short for "right-hand side") and it's followed by = Self.
This means that when a type implements Add, it doesn't need to specify the concrete type of rhs as would normally be required.
If it doesn't, rhs is assumed to be the same type as the type implementing the trait.
In our case, that will mean that rhs will be ShoppingList, because we're adding two shopping lists together.
The second part of the where clause specifies a type Self.Output.
This is called an associated type.
It's a generic type that the trait implementation must specify.
We'll see how it's different from a regular generic type like rhs in a moment.
For now, let's implement Add for ShoppingList, with a quick recap of how ShoppingList is defined:
// File: examples/chapter_14_traits/shopping_list_add.ob
use std.ops Add
struct ShoppingList {
map: HashMap<String, Integer>,
fn new() -> Self {
Self {
map: HashMap.new(),
}
}
}
impl Add for ShoppingList {
type output = Self
fn add(self, other: Self) -> Self.Output {
let combined_map = HashMap.new()
for (key, value) in self.map {
combined_map[key] = value
}
for (key, value) in other.map {
combined_map[key] = combined_map[key].unwrap_or(0) + value
}
ShoppingList {
map: combined_map,
}
}
}
fn main() {
let shopping_list_a = ShoppingList.new()
shopping_list_a.map["apple"] = 3
shopping_list_a.map["carrot"] = 1
let shopping_list_b = ShoppingList.new()
shopping_list_b.map["banana"] = 1
shopping_list_b.map["carrot"] = 1
let final_shopping_list = shopping_list_a + shopping_list_b
print(final_shopping_list)
}
When implementing Add for ShoppingList, we specify Self.Output, the type that the add function will return, as Self.
As noted, because the type parameter rhs defaults to Self, which is the type we want in this case, we don't need to specify its concrete type in the implementation.
If it were required, the first line of the implementation would read impl Add<ShoppingList> for ShoppingList but it would mean the same thing.
Essentially this definition says, "When you add a ShoppingList to another ShoppingList, you get a ShoppingList back."
The implementation of the add method creates an empty hash map, copies over each key/value pair from self (which is the ShoppingList on the left-hand side of the addition operation), and then copies over each key/value pair from other (the ShoppingList on the right-hand side), taking care to add quantities to existing ones if there was already a value for a particular key in the hash map.
Finally, a new ShoppingList with the new hash map is constructed and returned.
Warning: The
Addtrait is not yet mapped to the+operator. The version of the above program found in the examples directory uses a methodaddonShoppingListinstead to account for this.
Warning: The
Selftype alias is not yet available. The version of the above program found in the examples directory usesShoppingListinstead to account for this.
Multiple implementations of the same trait
Because rhs is a type parameter and we used the default type Self in our implementation, we can only add ShoppingLists to other ShoppingLists.
If we wanted to allow other types to be added to a ShoppingList, we'd need an additional implementation of the trait.
Perhaps a useful one would be (String, Integer), a tuple of two values that match the key/value relationship inside ShoppingList.
// File: examples/chapter_14_traits/shopping_list_add_tuple.ob
use std.ops Add
struct ShoppingList {
map: HashMap<String, Integer>,
fn new() -> Self {
Self {
map: HashMap.new(),
}
}
}
impl Add<(String, Integer)> for ShoppingList {
type output = Self
fn add(self, other: (String, Integer)) -> Self.Output {
let combined_map = HashMap.new()
for (key, value) in self.map {
combined_map[key] = value
}
combined_map[other.0] = combined_map[other.0].unwrap_or(0) + other.1
ShoppingList {
map: combined_map,
}
}
}
fn main() {
let shopping_list = ShoppingList.new()
shopping_list.map["apple"] = 3
shopping_list.map["carrot"] = 1
shopping_list = shopping_list + ("banana", 1) + ("carrot", 1)
print_line(shopping_list)
}
This implementation of the trait is similar, but we specify rhs explicitly with trait Add<(String, Integer)>, because in this case, rhs is a (String, Integer) rather than a ShoppingList.
In the method body, we copy over just the one key/value pair represented by the tuple into the new hash map.
With these two versions of the trait implemented, we can now add to ShoppingLists in two different ways!
This illustrates the difference between the generic type rhs and the associated type Self.Output.
The concrete type of rhs for a given usage is determined by the calling code.
When the expression on the right-hand side of the + is another shopping list, rhs = ShoppingList.
When the expression on the right-hand side of the + is a tuple, rhs = (String, Integer).
In contrast, the concrete type of Self.Output is determined by the trait implementation.
The calling code can't change the fact that when two ShoppingLists are added, a Self.Output = ShoppingList and a new ShoppingList is produced.
Likewise, the calling code can't change the fact that adding a (String, Integer) to a ShoppingList produces a new ShoppingList as well.
In our case, Self.Output is just Self, so adding something to a ShoppingList produces a new ShoppingList.
However, this Output associated type allows for the flexibility of the result of the operation being a different type than its operands.
Warning: The type checker is not implemented yet, so adding a second implementation of a trait for a type will overwrite the previous one. As such, the two example programs in this chapter cannot currently be combined into one.
Warning: The
Addtrait is not yet mapped to the+operator. The version of the above program found in the examples directory uses a methodaddonShoppingListinstead to account for this.
Warning: The
Selftype alias is not yet available. The version of the above program found in the examples directory usesShoppingListinstead to account for this.
Writing tic-tac-toe in Ybixo
We've learned enough now that we can write some actually useful programs. We're going to write a version of the game tic-tac-toe that you can play with a friend in your terminal.
We'll start by looking at the complete program. Then we'll break it down bit by bit to understand how it works and how it uses all the things we've learned in this book (as well as a few new things).
Paste the code below into an Ybixo file and run it with obc run to try the game.
// File: examples/chapter_15_game/tic_tac_toe.ob
use std.io read_line
enum Player {
X,
O,
}
enum Winner {
Player(Player),
Draw,
NoneYet,
}
struct Game {
player: Player,
rows: List<List<Option<Player>>>,
fn winner(self) -> Winner {
for row in (0..2) {
match (self.rows[row][0], self.rows[row][1], self.rows[row][2]) {
(Some(a), Some(b), Some(c)) -> {
if a == b && b == c {
return Winner.Player(a)
}
},
_ -> (),
}
}
for column in (0..2) {
match (self.rows[0][column], self.rows[1][column], self.rows[2][column]) {
(Some(a), Some(b), Some(c)) -> {
if a == b && b == c {
return Winner.Player(a)
}
},
_ -> (),
}
}
match (self.rows[0][0], self.rows[1][1], self.rows[2][2]) {
(Some(a), Some(b), Some(c)) -> {
if a == b && b == c {
return Winner.Player(a)
}
},
_ -> (),
}
match (self.rows[0][2], self.rows[1][1], self.rows[2][0]) {
(Some(a), Some(b), Some(c)) -> {
if a == b && b == c {
return Winner.Player(a)
}
},
_ -> (),
}
for row in self.rows {
for column in row {
match column {
Some(player) -> (),
None -> return Winner.NoneYet,
}
}
}
Winner.Draw
}
fn choose(self, cell: Integer, player: Player) -> Boolean {
if !(1..9).contains(cell) {
return false
}
match self.rows[(cell - 1) / 3][(cell - 1) % 3] {
Some(_) -> false,
None -> {
self.rows[(cell - 1) / 3][(cell - 1) % 3] = Some(player)
true
}
}
}
}
fn main() {
let game = Game {
player: Player.X,
rows: List.times(3, fn () { List.times(3, fn () { None }) }),
}
loop {
print_line("Game board:")
for row in game.rows {
for cell in row {
match cell {
Some(player) -> print(player),
None -> print("."),
}
}
print_line("")
}
print_line("")
match game.winner() {
Winner.Player(player) -> {
print_line("The winner is #{player}!")
break
},
Winner.Draw -> {
print_line("It's a draw!")
break
},
Winner.NoneYet -> (),
}
loop {
print("It's #{game.player}'s turn. Enter the cell to play (1-9): ")
match read_line().map(fn (s) { s.to_i() }) {
Ok(cell) -> {
if game.choose(cell, game.player) {
game.player = match game.player {
Player.X -> Player.O,
Player.O -> Player.X,
}
break
}
},
_ -> (),
}
print_line("")
print_line("You entered an invalid cell number.")
}
print_line("")
}
}
How to play
In case you're not familiar with tic-tac-toe, here's a short description of how it works, and how we interface with our command line version of the game.
Game board:
...
...
...
It's X's turn. Enter the cell to play (1-9):
At the start of the game, we're presented with a three-by-three grid of cells represented by dots, and a prompt for the first player's turn. Two players, X and O, will take turns placing their marks on the grid. A cell is selected by choosing a number from 1 to 9, with each number corresponding to its position on the grid when read left to right, top to bottom:
123
456
789
The goal is to be the first player to get three of their marks in a line, either horizontally, vertically, or diagonally.
Give it a shot, either with a friend, or playing against yourself, controlling both X and O. Try to find ways to break the game: entering input outside the 1 to 9 range, entering a blank line as input, playing a cell that's already taken, etc.
Breaking down the program
Imports
use std.io read_line
The program starts with the one function we need from the standard library that is not imported by default: read_line from std.io.
We've seen this several times before.
It's what we use to read text the user enters on the command line into our program.
Data types
enum Player {
X,
O,
}
enum Winner {
Player(Player),
Draw,
NoneYet,
}
struct Game {
player: Player,
rows: List<List<Option<Player>>>,
// ...
}
We model the data for tic-tac-toe with three custom data types.
Player is an enum representing the two players, X and Y.
We'll use this to keep track of whose turn it is, the marks placed on the board, and who the winner is.
This enum has two unit variants that hold no data, because the name of the variant contains enough information for our needs.
Winner is another enum representing the state of the win condition.
At any point in the game, there are four possible cases:
- Player X won
- Player O won
- Neither player won and there are no more open cells
- Neither player won and there are still open cells
Again, we represent these cases with enum variants, collapsing the first two into a single variant that holds a Player, telling us which of the two players won.
Game is a struct that represents the state of the overall game.
It has two fields, player, which tracks whose turn it is, and rows, which tracks the grid and the contents of each cell.
The type of the rows field might look a little unwieldy, but it's not too complicated.
It's a list of lists.
Each element in the outer list is another list.
This represents our rows.
Each element in the inner list is an optional player.
This represents the columns in a row and whether or not a player has left a mark in the corresponding cell.
Creating the initial state
The Game type has two methods, but let's ignore them for now and take a look at the main loop that runs the game.
The main function begins by initializing the state for a new game:
let game = Game {
player: Player.X,
rows: List.times(3, fn () { List.times(3, fn () { None }) }),
}
We create a game with the initial player being X.
We initialize the grid with a fancy expression that creates our nested lists.
This might be a little tricky to read, so let's add some whitespace and comments to help see what's happening:
// Create outer list (rows)
List.times(
3, // Create three rows
fn () {
// Create inner list (columns/cells)
List.times(
3, // Create three columns/cells
fn () {
// Each cell is empty to start
None
}
)
}
)
List.times is a static method on List from the standard library that allows us to create a list of a known size and to populate each of its elements with a known value.
Here's its signature:
fn times(n: Integer, f: f) -> List<t> where f: Fn() -> t
The function takes an integer, which is the number of elements the new list should have, and a closure.
The closure returns a value that should be used to populate each element in the new list.
Since we're creating a grid, represented as rows of columns, we create a list of three elements to represent the three rows.
The value of each element in a row is another list of three elements, hence the inner List.times call.
The value of the elements in the inner list (our columns/cells) begins as None, the variant of Option<Player> indicating that neither player has left their mark in that cell yet.
Printing the board
loop {
print_line("Game board:")
for row in game.rows {
for cell in row {
match cell {
Some(player) -> print(player),
None -> print("."),
}
}
print_line("")
}
// ...
}
The main game loop is an actual loop!
The loop will continue until we explicitly break out of it.
In game terms, this means that the players will keep making moves until one of them wins, or until there's a draw.
Each loop of the game begins by printing a visualization of the grid. We do this with a nested loop, iterating through the rows and the cells within those rows. For each cell, we check whether or not there's a player mark. If there is, we print that mark (X or O). If the cell is still empty, we print a dot. After each row, we print a blank line so that each row of cells will appear on its own line in the output.
Checking the win condition
match game.winner() {
Winner.Player(player) -> {
print_line("The winner is #{player}!")
break
},
Winner.Draw -> {
print_line("It's a draw!")
break
},
Winner.NoneYet -> (),
}
Next, we check to see if the game should end.
We call the winner method on our Game type to inspect the state.
winner returns a value of our custom Winner type.
We match on this Winner to decide what to do:
- If a player won, we print the name of the winner and then break out of the loop, ending the game.
- If neither player won, we print that it's a draw and then break out of the loop, ending the game.
- If neither player won, but there are still legal moves, there's nothing to do, so we give this match arm an empty tuple (unit) expression, which will do nothing and proceed to the next part of the loop.
Determining the win condition
Here's the definition of the winner method, which determines the win condition, if any:
fn winner(self) -> Winner {
for row in (0..2) {
match (self.rows[row][0], self.rows[row][1], self.rows[row][2]) {
(Some(a), Some(b), Some(c)) -> {
if a == b && b == c {
return Winner.Player(a)
}
},
_ -> (),
}
}
for column in (0..2) {
match (self.rows[0][column], self.rows[1][column], self.rows[2][column]) {
(Some(a), Some(b), Some(c)) -> {
if a == b && b == c {
return Winner.Player(a)
}
},
_ -> (),
}
}
match (self.rows[0][0], self.rows[1][1], self.rows[2][2]) {
(Some(a), Some(b), Some(c)) -> {
if a == b && b == c {
return Winner.Player(a)
}
},
_ -> (),
}
match (self.rows[0][2], self.rows[1][1], self.rows[2][0]) {
(Some(a), Some(b), Some(c)) -> {
if a == b && b == c {
return Winner.Player(a)
}
},
_ -> (),
}
for row in self.rows {
for column in row {
match column {
Some(player) -> (),
None -> return Winner.NoneYet,
}
}
}
Winner.Draw
}
This is a lot of code, but conceptually, it's straightforward:
- We check if any of the rows have each cell marked by the same player. If any does, we return that player as the winner.
- We check if any of the columns have each cell marked by the same player. If any does, we return that player as the winner.
- We check both possible diagonals (cells 1/5/9 and cells 3/5/7) to see if either is marked entirely by the same player. If either does, we return that player as the winner.
If we get past this point, it means no one has won yet, so it's either a draw or the game is still in progress. Finally:
- We check if there are any empty cells.
If there are, we return
Winner.NoneYetto indicate the game should continue. If there aren't, we returnWinner.Drawto indicate the game has ended without a winner, as there are no other possibilities.
Let's look at one of these checks in a little more detail to see how it's done:
for row in (0..2) {
match (self.rows[row][0], self.rows[row][1], self.rows[row][2]) {
(Some(a), Some(b), Some(c)) -> {
if a == b && b == c {
return Winner.Player(a)
}
},
_ -> (),
}
}
This loops through each row in the grid and checks for rows that have all of the same mark.
The expression (0..2) creates a value of type Range that is used to represent a range of values in a meaningful order.
The first number is the start of the range and the second number is the end of the range.
A range defined this way is inclusive of the second number, so in this case, we're creating a range that covers the values 0, 1, and 2.
If we wanted to create an exclusive range, we'd write (0..<2), which would cover only the values 0 and 1.
Ranges can be iterated with a for loop.
We loop over the three integers, with each bound to the variable row.
In each iteration, we match against a tuple of each cell in that row.
self.rows is the list of rows, [row] takes the row we want to check from the list, and then the [0], [1], and [2] indexes get the value of the first, second, and third column of that row.
The type of each cell is Option<Player> so we're matching on a value of this type:
(Option<Player>, Option<Player>, Option<Player>)
There are a lot of combinations of possible values here, but the only one we care about is the one where all three are the same player, so we have a match arm for the case where all three are Option.Some.
If they're all Some, we check if the player is the same for each one, and if so, declare that player the winner by returning Winner.Player.
To make sure the match is exhaustive, we have one more match arm that matches on all other cases using the _ wildcard.
That case just evaluates to an empty tuple (unit) and does nothing.
Reading the player's cell choices
The last part of our game is the logic that reads input from the current player and tries to mark the cell they choose.
loop {
print("It's #{game.player}'s turn. Enter the cell to play (1-9): ")
match read_line().map(fn (s) { s.to_i() }) {
Ok(cell) -> {
if game.choose(cell, game.player) {
game.player = match game.player {
Player.X -> Player.O,
Player.O -> Player.X,
}
break
}
},
_ -> (),
}
print_line("")
print_line("You entered an invalid cell number.")
}
Note that this is a second loop inside our main game loop.
We use game.player to prompt the player whose turn it is to choose a cell, then we read their choice.
Recall that read_line returns Result<String, String> so it might succeed and give us the input we want, or it might fail with some sort of I/O error.
By using map, we provide a closure that parses the input into an integer, but only in the case that read_line returns the Result.Ok variant.
By parsing the input into a number, we turn values like "1" into the integer 1, so we can perform numeric operations on them.
We match on this mapped result and check for the Ok case.
If we got a valid integer back, we attempt to place their mark in the chosen cell by passing both the cell number and the current player to our Game type's choose method.
choose returns a boolean indicating whether or not the player's mark was successfully placed in the cell.
If it was, we use a match expression to reassign the value of game.player to the opposite of the current player, which effectively changes which player's turn it is on the next iteration of the main game loop.
Then we break out of the inner loop to start a new turn.
If read_line returned an Err or game.choose returned false, we fall through to the print_line statements at the bottom, letting the player know that whatever input they entered either couldn't be read or wasn't an available cell.
The inner loop then starts again, and continues the same process until the player chooses a valid cell.
Warning:
s.to_iis just Ruby'sString#to_imethod exposed directly, which does loose conversions like parsing"a"as0. This program is written assuming the input will always successfully parse into a valid integer, but that isn't guaranteed. In the future, Ybixo will provide a safer technique for parsing integers from strings.
Choosing a cell
The last piece of code to look at is the definition of the choose method.
Again, this method's job is to try placing a player's mark in the given grid cell, and then returning a boolean value to indicate whether or not it succeeded.
fn choose(self, cell: Integer, player: Player) -> Boolean {
if !(1..9).contains(cell) {
return false
}
match self.rows[(cell - 1) / 3][(cell - 1) % 3] {
Some(_) -> false,
None -> {
self.rows[(cell - 1) / 3][(cell - 1) % 3] = Some(player)
true
}
}
}
The method begins with a conditional expression to check whether or not the integer entered by the player corresponds to a cell in the grid.
We do this by creating a range of all valid cell numbers with (1..9) and then calling the contains method on the range to check whether a specific element is part of that range.
If it isn't, we know the player's choice was out of bounds, and hence an invalid value, so we return false.
If the value is within the range, then we check whether or not there's already a mark in that cell by matching on the current value of the cell.
The fancy arithmetic inside the indexing operations are used to convert the 1 to 9 range that we accept as cell numbers to the corresponding offsets in our list representation of the grid.
(cell - 1) / 3 changes our 1-based offsets to the 0-based offsets used by list indexing and then divides that by 3 so that we get the right index regardless of which row the player picked.
(cell - 1) % 3 does somthing similar, but uses the % (modulo) operator to get the remainder of a division by three, which ends up being the correct list offset for the columns within each row.
If we find that there's already a mark in the cell, we return false.
We again use the _ wildcard to match the player in the Some case, because we don't care which player has marked the cell.
We only care whether or not there is a mark in the cell.
If there's no mark, we set the value of that cell (using the same indexing expression) to the player who chose it.
This modifies our game state.
Then we return true to let the main loop know that the choice was valid and that we can proceed to the next turn.
And that's our game of tic-tac-toe!
Exploring Ybixo further
You made it to the end of the Ybixo book! Congratulations and thank you for reading. If you have any feedback on the book you'd like to share, you can do so by opening an issue. It would be very much appreciated!
If you want to work on Ybixo itself, you'll need to learn Rust and Ruby.
A good place to start learning Rust is its official documentation: https://rust-lang.org/learn/. Rust's documentation includes a guide also called "the book" which is very much like this one. You'll find that Rust is very similar to Ybixo. Now that you've finished the Ybixo book, Rust should feel familiar.
Ruby's official documentation also has lots of resources that will be useful in learning Ruby: https://www.ruby-lang.org/en/documentation/.
Now go write some Ybixo programs!
Ybixo compared to Rust and Ruby
Ybixo is very similar to Rust, but produces Ruby source code rather than native code. Why might you use Ybixo over Rust or Ruby?
Since Ybixo is an experimental language in early development, if you're writing a program for production deployment, the answer is that you shouldn't. But if you're interested in learning and experimenting, there are a few fundamental trade-offs.
Compared to Rust
Ybixo's syntax and feature set are quite similar to Rust. The biggest difference is that Ybixo is not a systems programming language. That means it does not offer the programmer explicit features for memory management and other low-level operations. It's intended for high-level programs that would otherwise have been suitable for a language like Ruby or Python.
Ybixo also lacks one of Rust's distinguishing features, the tracking of reference lifetimes. Since Ybixo is executed by the Ruby interpreter, data is generally stored on the heap and garbage collected.
Closely related to reference tracking is another key feature of Rust's type system: move semantics, which allows "ownership" of a value to be moved from one place to another, preventing its use at the original place. Ybixo has no equivalent to this behavior.
Ybixo is also not strict about the mutability of values. This means that Ybixo programs are not protected from data races, whereas in Rust they are prevented statically.
Finally, because Ybixo produces Ruby source code rather than native code, Rust programs will be signficantly more performant.
If you like Rust's type system and feature set, but aren't writing a program that needs manual memory management, strict protection of immutable data, or maximal performance, Ybixo should be easier to use.
Specific examples of some of the differences between the two languages follow.
Memory allocation
Rust allows you to choose whether data is on the stack or on the heap:
fn main() { let on_the_stack = 1u8; let on_the_heap = Box::new(1u8); }
Ybixo has no such control. All data is allocated on the heap. This makes Ybixo easier to use because you don't need to think about the distinction and all types can be dynamically sized. However, this comes with a performance trade-off, since the bookkeeping required to manage heap memory has a cost.
References and mutability
The following Rust program will not compile because the function change_container_contents attempts to mutate a value through an immutable reference:
struct Container(String); fn change_container_contents(container: &Container) { container.0 = String::from("Hello, mutability"); } fn main() { let container = Container(String::from("Hello, world")); change_container_contents(&container); println!("{}", container.0); }
In Ybixo, there are neither explicit references nor is there a way to mark a name as immutable, so a working equivalent would be:
struct Container(String);
fn change_container_contents(container: Container) {
container.0 = "Hello, mutability"
}
fn main() {
let container = Container("Hello, world")
change_container_contents(container)
print_line(container.0)
}
This means that Ybixo is easier to write, because you don't need to keep track of references and mutability. The trade-off is that you can introduce bugs by mutating data that should be immutable.
Move semantics
In Rust, moving a value (passing it by value rather than by reference) prevents access to it at its original location:
fn move_data(data: String) {} fn main() { let data = String::from("Hello, move semantics"); move_data(data); println!("{data}"); // Error: borrow of moved value: `data` }
Ybixo's type system does not include this behavior, so the equivalent program will run:
fn move_data(data: String) {}
fn main() {
let data = "Hello, move semantics"
move_data(data)
print_line(data)
}
Move semantics are an important part of the system that provides Rust programs memory safety. Since Ybixo is executed by the Ruby interpreter, which uses garbage collection, programs are still memory safe, even without move semantics.
Compared to Ruby
Ybixo doesn't have much superficial similarity to Ruby. The biggest difference between the two is that Ybixo is statically typed. This means that Ybixo will statically eliminate many classes of errors that happen at runtime in Ruby programs.
On the flip side, Ybixo programs are greatly constrained in terms of the dynamic behavior that is possible in Ruby. A primary is example is metaprogramming. Ybixo does not have a macro system, so there is no equivalent of Ruby's metaprogramming facilities, such as dynamic method definition and execution.
Ruby does not support file-scoped code. A Ruby program written across multiple files will simply "require" one file from another, and the code in both files are executed in the same global, mutable namespace. Ybixo has a module system in which files are isolated from each other and items from other files are brought into scope with explicit imports. This improves local reasoning and makes name conflicts explicit.
Ruby uses exception-based error handling, while Ybixo uses value-based error handling. This makes it easier to get a quick prototype working in Ruby, but it will be susceptible to runtime errors that would not be possible in Ybixo. Additionally, it is easier to reason about failure cases in Ybixo.
Ruby optimizes its syntax for calling functions at the expense of referencing functions. This allows functions to be called without parentheses, but makes it more awkward to assign functions to variables that can be passed as function arguments. Ybixo requires parentheses for calling functions, so a function can be easily treated as a first-class value by omitting the parentheses.
Although Ybixo produces Ruby source code, many of the features of Ruby are not exposed by Ybixo, such as its multiple types of anonymous functions. If you want to write a program that interfaces with existing Ruby libraries, it will not be possible in Ybixo.
Ybixo is a good fit if you're writing a program with no Ruby dependencies and you want more protection from runtime errors than Ruby offers.
Specific examples of some of the differences between the two languages follow.
Dynamic typing
Trivial type errors will cause Ruby programs to crash at runtime:
string = 3.14159
# At runtime, will raise:
# NoMethodError: undefined method 'upcase' for an instance of Float
string.upcase
The equivalent Ybixo program would fail to compile due to the type mismatch in second line.
Metaprogramming
Ruby can define methods at runtime with metaprogramming techniques:
["apples", "bananas", "carrots"].each do |fruit|
define_method(fruit) do
"I love to eat #{fruit}!"
end
end
puts apples # Prints "I love to eat apples!"
puts bananas # Prints "I love to eat bananas!"
puts carrots # Prints "I love to eat carrots!"
Ybixo has no equivalent of this behavior. Instead, an Ybixo program would be less clever and simply define one function with a parameter for the fruit:
fn lovely_fruit(fruit: String) -> String {
"I love to eat #{fruit}!"
}
fn main() {
print_line(lovely_fruit("apples"))
print_line(lovely_fruit("bananas"))
print_line(lovely_fruit("carrots"))
}
The difference might seem unimportant in this trivial example, but metaprogramming can be used to signficantly reduce the amount of source code required in a program. Ybixo is more straightforward at the expense of being more verbose.
Modules
In Ruby, requiring code in another file can mutate the global namespace in any way:
class Car
def drive
"Driving the car!"
end
end
require "truck"
Car.new.drive
Truck.new.drive
In the above program, there is no way to tell from this file's source code what effect requiring "truck" had.
The lines below the require suggest that it defined a class called Truck, but this is simply assumed, and will crash at runtime if the assumption is wrong.
Furthermore, the "truck" file could have defined its own Car class which would overwrite the local one due to the require coming after the local definition.
Because of this, it's not possible to know from looking at this source code what Car.new.drive will actually do at runtime.
In Ybixo, the only side effect importing from a module has is that, if it has a main function, it will be run automatically, which is useful for initialization code.
However, any impact on the local file's namespace is explicitly controlled with named imports.
The equivalent Ybixo program would be:
struct Car {
fn drive(self) -> String {
"Driving the car!"
}
}
use truck Truck
fn main() {
let car = Car {}
car.drive()
let truck = Truck {}
truck.drive()
}
In this program, code inside the "truck" module cannot modify the behavior of Car, and regardless of what items it defines, only the explicitly imported Truck type is exposed to the local module.
Error handling
Ruby programs propagate and handle errors with exceptions. Any code can raise an exception, which will propagate up the stack and crash the program at runtime, unless it is explicitly rescued and handled by code higher up the stack.
def might_crash
will_crash
rescue StandardError
end
def will_crash
raise StandardError
end
def main
# We cannot tell from looking at the signature of `might_crash` if this is safe
might_crash
end
Because any function can raise an exception of any type, you cannot look at a function's signature to know all of the possible failure cases that should be handled.
Ybixo requires that fallible functions indicate their fallibility as part of their signature, making it very clear to callers what the failure cases are and how to handle them:
fn might_error() -> Result<(), String> {
will_error()
}
fn will_error() -> Result<(), String> {
Err("something bad happened")
}
fn main () {
match might_error() {
Ok(_) -> print_line("It succeeded"),
Err(error) -> print_line("It failed: #{error}"),
}
}
Functions as first-class values
In Ruby, writing the name of a function calls it. Referencing a function as a value requires extra ceremony:
greet # Calls the greet method
method(:greet) # Creates a first-class "method object" from greet
The equivalent Ybixo is more succinct:
greet() // Calls the greet function
greet // References the greet function
Anonymous functions
Ruby has three forms of anonymous functions: implicit blocks, procs, and lambdas.
class MyArray
def initialize(array)
@array = array
end
def each
for element in @array
yield element
end
end
end
my_array = MyArray.new([1, 2, 3])
my_array.each do |element|
puts element
end
In this example, the last expression calls MyArray#each with an implicit block not mentioned in the signature of MyArray#each and the yield keyword is used to execute this implict block with a parameter.
Ruby can optionally make this pattern explict by "capturing" the block with the & operator, which converts the implicit block to a proc object and changes the way it is executed:
def each(&block)
for element in @array
block.call(element)
end
end
The form of the anonymous function can also be changed at the call site. The caller can create a "proc" and pass it as an explicit argument.
my_array = MyArray.new([1, 2, 3])
my_proc = Proc.new do |element|
puts element
end
my_array.each(my_proc)
In either form of MyArray#each shown above, passing an explicit proc will cause an ArgumentError at runtime.
Instead, MyArray#each would have to be defined in yet another form, with a single parameter which gives no visual indication in the signature that the parameter is callable:
def each(block)
for element in @array
block.call(element)
end
end
And there is yet another form of anonymous function, the lambda:
my_array = MyArray.new([1, 2, 3])
my_lambda = -> (element) do
puts element
end
my_array.each(my_lambda)
In this example, the difference between a proc and a lambda has no impact on its usage with MyArray#each.
The difference between the two is that the lambda has stricter behavior with regards to argument arity and control flow keywords.
Using return from inside a lambda will return from the lambda, whereas using return from inside a proc will return from the function where the proc is called.
In Ybixo, none of this complexity is present. There is only one form of anonymous function, the closure, and functions must be explicit that they accept them as parameters in their signatures:
struct MyArray<t> {
array: List<t>,
fn each(self, f: f) where f: Fn() {
for element in self.array {
f(element)
}
}
}
fn main() {
let my_array = MyArray {
array: [1, 2, 3],
}
my_array.each(fn (element) {
print_line(element)
})
}
In Ybixo, closures behave exactly like functions with regard to control flow keywords, so they have no ability to return early from the function in which they are called.