Welcome
This book acts as the specification for the Lazy programming language! Every detail about the language (grammar, data types, memory management, JIT) is provided, as well as examples.
Lazy is a statically typed, interpreted programming language.
A taste
struct Person {
name: str,
age: f32,
hobby: str?,
age_up: fn() {
self.age += 1;
}
}
enum AnimalTypes {
Dog,
Cat,
Fish,
Bird
}
struct Animal {
name: str,
age: i8,
type: AnimalTypes
}
type ThingWithName = {name: str};
main {
let me = new Person { name: "Google", age: 19 };
let my_pet = new Animal { name: "Baby", age: 1, type: AnimalTypes::Bird };
let things = new Vec<ThingWithName>{};
things.push(me);
things.push(my_pet);
for thing in things {
print(thing.name);
}
}
Primitives
Much like the rest programming languages, Lazy provides a bunch of primitive data types.
Scalar types
- Signed integers:
i8
,i16
,i32
- Unsigned integers:
u8
,u16
,u32
- Floating points:
f32
- Characters:
char
- Strings:
str
- Booleans:
true
orfalse
- The
none
data type
Compound types
Tuples
Tuples can hold values of different types:
const items<[str, i32, bool]> = ["Hello", 64, true];
items.1; // 64
Arrays?
Lazy
does not support arrays natively, but it does provide a Vec
struct, which is an array located on the heap. Those are always more useful anyways.
let nums = Vec::from(1..=10);
nums.push(15);
nums.filter(fn(n) n % 2);
Literals
Almost all literals are what'd you expect them to be.
Numbers
let my_age = 18; // inferred type: i32
let my_height = 6.1; // inferred type: f32
let not_inferred: u8 = 12; // Not inferred, number is an u8
Numbers can contain underscores (_
) to improve readability:
let big_num = 100_00_00;
let same_big_num = 1000000;
big_num == same_big_num; // returns "true"
To improve working with milliseconds, numbers can be prefixed with s
, m
, h
and d
to be automatically converted. This is done during compile time, so there's zero overhead!
let one_min_in_milliseconds = 1m;
one_min_in_milliseconds == 60000; // returns "true"
Binary / Octal / Hex literals
Numbers can be prefixed with 0b
, 0o
and 0x
to be parsed as binary / octal / hex numbers.
0b010101 == 21 // true
0o03621623 == 992147 // true
0x0123ABCd == 19114957 // true
Template literals
String concatenation is very messy in many languages - Lazy makes this easy with template literals:
let age = 1 + 17;
print(`Hello World! I am ${age} years old`); // Hello World! I am 18 years old
They work exactly like in javascript!
Iterators
0..5; // Creates an iterator from 0 (inclusive) to 5 (exclusive), so 0, 1, 2, 3 and 4.
5..=10; // Creates an iterator from 5 (inclusive) to 10 (inclusive)
..5; // Short syntax for 0..5
..=10; // Short syntax for 0..=10
Functions
In Lazy
, functions are first-class citizens, you can pass them as parameters, save them in variables and so on.
let my_fn = fn() 1 + 1;
my_fn(); // returns 2
Natural literals
natural
literals are tuples
or iterators
which can only contain literals. For example:
person.age..=18; // This is NOT a natural iterator
0..=18; // This IS, because both the start and the end are literals
[1, 2, none, true] // This IS a natural tuple
[1, 2, func(1 + 1)] // This is NOT a natural tuple
Only natural tuples
or iterators
are allowed in match
arms.
Functions
In Lazy, functions are first-class citizens, this means they can be passed as function arguments, stored easily in data structures, and be saved in variables.
main {
const say_alphabet_lowercase = fn () print(...'a'..='z');
say_alphabet_lowercase();
}
A function may have up to 255 arguments!
Function return values
A return value type can be specified to any function by adding and arrow and the type after the arguments part:
const is_ok = fn() -> bool {
true;
}
As you notice, there's no return
statement in Lazy
. The last expression in the function body always gets returned. Everything in Lazy
is an expression, except type
, struct
and enum
declarations, so it's all good.
If a return type is not provided, the function will always return none
.
Optional parameters
const add(num1: i32, num2: i32, num3: i32?) -> i32 {
num1 + num2 + (num3 || 0);
}
Default values
const add(num1: i32, num2: i32, num3 = 0) -> i32 {
num1 + num2 + num3;
}
Execution context
All functions in Lazy
have an execution context which can be referenced via self
in the function body. The execution context of functions is always none
, unless the function is defined inside a structure:
struct Car {
model: str,
beep_noise: str,
beep: fn() {
print(self.beep_noise);
}
}
Iterators
Iterator
is a type which allows you to loop over a struct. for...in
loops are powered by iterators. An Iterator
is any struct which satisfies the following type:
type Iterator<T> = { next: () -> T? }
Any struct with a "next" function is a valid iterator!
struct RangeIter {
min: i32
max: i32
progress: i32
next: () -> i32? {
if self.progress == self.max none
else self.progress += 1
}
}
main {
let my_range = new Range { min: 0, max: 15, progress: 0 };
for i in my_range print(i);
}
Literals
Iterator literals are called range
iterators, because they create an iterator that iterates through a range of numbers.
0..5 // Exclusive
0..=5 // Inclusive
Spreading iterators
The spread
syntax can be used to turn iterators into vectors!
let iterator = 0..10;
let vec = ...iterator;
print(vec.to_string()); // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
Spread syntax in functions
The spread
syntax has a special meaning in function parameters:
type Stringable = { to_string: () -> str };
const print = fn(...strs: Stringable?) {
// code
}
The ...strs:
syntax means that the function can receive an unlimited amount of values with the Stringable
type. The strs
value itself is either a Vector
which contains Stringable
, or none
..
const println = fn(...strs: Stringable?) {
for string in strs {
print(string?.to_string() + "\n");
}
}
print_many("Hello", new Vec{}, 4, true);
// Prints:
/*
Hello
[]
4,
true
*/
Custom Types
There are two ways to create custom types in Lazy:
enum
- For creating enumerations with different variants, which can hold values of different typesstruct
- Structs are very similar to C structs, except they can contain functions
Type aliasing
Type aliasing can be done by using the type
keyword:
type i = i8;
main {
let a: i = 4;
}
None-able types
A type which is prefixed with ?
is none
-able. The value it may have may be none
or the type.
static div = fn(a: i32, b: i32) -> i32? {
if b == 0 none
else a / b
}
Enums
Enums are types which may be one or a few different variants.
enum Number {
Int: i32
Float: f32
}
enum TokenTypes {
String: str
Integer: i32
Number: Number
Character: char
Invalid
}
main {
let str_token = TokenTypes::String("some token type");
str_token == TokenTypes::String; // returns true
// str_token gets automatically unwrapped
if str_token == TokenTypes::String print(str_token) // prints "some token type"
else if str_token == TokenTypes::Integer print(str_token + 5)
let num_token = TokenTypess::Number(Numbers::Int(3000));
}
"Unwrapping" enums
By default all items are "wrapped" - their values and unknown and can be anything.
let char_token = TokenTypes::Char('a');
// Even if it's clear that char_token is of type `char`, Lazy doesn't allow you to use it.
To use the value inside the variant you'll have to "unwrap" it.
if unwrapping
if let TokenTypes::Char(ch) = char_token {
// You can use `char_token` as a character now.
ch.to_string(); // "a"
}
// char_token is wrapped here.
if char_token == TokenTypes::Char('a') {
// char_token is still wrapped, but you know it's value.
}
match unwrapping
match char_token {
TokenTypes::Char(ch) => {
print(ch);
// char_token is unwrapped here
},
_ => {
// char_token is wrapped, this portion only executes when nothing else got matched
}
}
Attaching methods and properties to enums
Attaching methods and properties to enums is done via the impl keyword.
enum Option<T> {
Some: T,
None
}
type Unwrap<T> = {
unwrap: () -> T,
is_some: () -> bool,
unwrap_or: (v: T) -> T
}
// The impl block's typings must match the structure's
impl<T> Unwrap<T> for Option<T> {
unwrap: fn() -> T {
match self {
Option::Some(val) => val,
Option::None => error("Tried to unwrap an empty option!")
}
}
is_some: fn() -> bool {
self == Option::Some;
}
unwrap_or: fn(v: T) -> T {
match self {
Option::Some(val) => val,
Option::None => v
}
}
}
main {
let maybe: Option<i32> = Option::None;
maybe.unwrap_or(0); // 0
maybe = Option::Some(32);
maybe.is_some(); // true
maybe.unwrap(); // 32
}
Structs
Structs contain key-value pairs called fields
. All keys and the types of the values must be known at compile-time.
struct Something<GenericParam> {
val: GenericParam,
set: fn(val: GenericParam) -> bool {
self.val = val;
true;
}
}
Field modifiers
All of the following field modifiers can be used together on a single field, and the order of the modifiers does not matter.
Static fields
Sometimes you want a field to be attached to the struct itself and not an instance of the struct.
struct Person {
name: str,
static create: fn(name: str) -> Person {
new Person { name };
}
}
let me = Person::create("Google");
Hidden/Private fields
Fields that can only be accessed in the execution context of the functions inside the sctruct.
struct Person {
name: str,
private id: i32
static create: fn(name: str) -> Person {
new Person { name, id: rng() };
}
}
let me = Person::create("Google");
me.id; // Error!
Immutable fields
Fields that cannot be mutated.
struct Person {
const name: str,
private id: i32
static create: fn(name: str) -> Person {
new Person { name, id: rng() };
}
}
let me = Person::create("Google");
me.name = "Pedro"; // Error!
Optional fields
Fields which have a question mark (?
) after the type are optional.
struct StructWithOptionalField {
// Can either be "none", or "str"
something: str?
}
Accessing fields
Fields are accessed using the dot notation (.
).
main {
let my_smth = new Something<str>{val: "Hello"};
my_smth.set(val: "World");
my_smth.val.length; // returns 5
}
Accessing optional fields
Optional fields can be accessed by the dot notation, but you must put a question mark (?
) before the dot. If the optional struct is empty, then the expression returns none
.
main {
let my_struct = new StructWithOptionalField{}; // the optional field is `none`
my_fn(my_struct.something?); // The function doesn't get executed because my_struct cannot be unwrapped
my_struct.something = "Hello";
my_fn(my_struct.something?); // Runs fine!
}
Keep in mind that you cannot use optional fields before you make sure they are not none
.
if my_struct != none {
// my_struct is guaranteed to not be none here, no need for question marks!
print(my_struct.something);
}
Operator overloading
Operator overloading is done via partials.
import * from "std/ops" as Ops
struct Person {
first_name: str
middle_name: str
last_name: str
}
impl Ops::Add<Person, str> for Person {
add: fn(other: Person) -> str {
self.first_name + " " + other.middle_name + " " + other.last_name;
}
}
main {
const me = new Person {first_name: "Google", middle_name: "Something", last_name: "Feud"};
const you = new Person {first_name: "Taylor", middle_name: "Alison", last_name: "Swift"};
print(me + you); // Google Alison Swift
}
Partials
Lazy
does not have core OOP design patters like inheritance, but it has Partials
, which are very similar to rust's Traits
, but a little more dynamic.
Partial struct
struct Animal {
name: str,
age: i8,
make_noise: fn(noise: str?) print(noise || "*quiet*")
}
struct Human {
name: str,
age: i8,
job: str
make_noise: fn() print("Hello World")
}
type WithName = { name: str }
main {
// The function will only have access to the name field
let get_name = fn(thing: WithName) -> str {
thing.name;
}
let me = new Human{name: "Google", job: "Programmer", age: 19};
let some_animal = new Animal{name: "Perry", age: 2};
get_name(me); // Google
get_name(some_animal); // Perry
}
Combining types
Partials can be combined to create more complex partials:
type Stringable = {
to_string: () -> str
}
type Numberable = {
to_int: () -> i32
}
static num_and_str = fn(val: Stringable + Numerable) -> [str, i32] {
[val.to_string(), val.to_int()];
}
Partial function
// Requires the type to have a "make_noise" method - we don't care about params or return value in this case
type MakesNoise = { make_noise: () };
main {
let me = new Human{name: "Google", job: "Programmer", age: 19};
let some_animal = new Animal{name: "Perry", age: 2};
let stuff_that_makes_noise = Vec::create<MakesNoise>();
stuff_that_makes_noise.push(me);
stuff_that_makes_noise.push(some_animal);
stuff_that_makes_noise[0]?.make_noise(); // "Hello World"
stuff_that_makes_noise[1]?.make_noise(); // *quiet*
}
Impl
The impl keyword is used to combine a struct or an enum with a partial type, so the struct/enum fits the partial. Generally, this keyword doesn't have to be used with structs in order to make a struct compatible with a type, but it's nice syntactic sugar.
struct Human {
age: i8
}
struct Animal {
age: i8,
species: AnimalTypes
}
type Speak = { speak: (...args: str?) -> str } // A partial which requires the structure to have a "speak" method which returns a `str`
impl Speak for Human {
speak: fn(...args: str?) -> str {
if args.0 args.0;
}
}
impl Speak for Animal {
speak: fn(...args: str?) -> str {
match self.species {
AnimalTypes::Cat => "meow",
AnimalTypes::Dog => "woof",
AnimalTypes::Mouse => "*squick*"
}
}
}
Type guards
This feature can also be used to create type guards:
type TotalStrLen {
total_len: () -> i32
}
impl TotalStrLen for Vec<str> {
total_len: fn() -> i32 {
let len = 0;
for string in self {
len += string.length;
}
len;
}
}
main {
let vec_of_strs = Vec::from<str>("a", "b", "cde");
vec_of_strs.total_len(); // 5
let vec_of_nums = Vec::from<i32>(1, 2, 3, 4, 5);
vec_of_nums.total_len(); // Error!
}
Generic bounds
In struct
s, enum
s and type
s, generic parameters can be bounded. That means that only a type which satisfies the specified partial can be passed as a generic parameter.
struct Error<
T: { to_string: () -> str }
> {
content: T,
type: i32,
format: fn() -> str {
`Error ${self.type}: ${self.content}`;
}
}
struct CustomErrorContent {
vars: Vec<str>,
to_string: fn() -> str {
vars.join(", ");
}
}
main {
const my_custom_error_content = new CustomErrorContent { vars: Vec::from("Hello", " ", "World!") };
const error = new Error<CustomErrorContent> { content: my_custom_error_content, type: 1 };
print(error.format()); // "Error 1: Hello World"
}
Variables
Defining variables is done with the let
, const
and static
keywords.
let
let
defines a mutable variable. The value inside the variable itself is always mutable, but the variable can be changed.
let a = 5;
a = 10; // Valid
a = 3.14; // Invalid, `a` is implicitly an i32
const
const
defines an immutable variable. The value is still mutable, but the variable cannot be changed.
const a = 5;
a = 10; // Invalid
struct Pair<V> {
key: str,
val: V
}
const my_pair = new Pair<i32>{key: "Hello", val: 3};
my_pair.key = "World"; // Valid
static
static
is used for values you never want to get garbage-collected. They cannot be defined in functions or the main
block.
static PI = 3.14;
main {
PI = 4; // Invalid
let PI = 4; // Invalid
const PI = 5; // Invalid
}
Type hinting in variables
let someOption = none; // Incorrect
Here, the compiler doesn't know what else can someOption
be other than none, so it throws an error. You need to specify the other possible type.
let someOption: i32? = none; // Correct!
Deconstructing structs or tuples
let my_tuple = [1, 2, 3, 4, 5];
let [firstElement, secondElement] = my_tuple;
print(firstElement, secondElement); // 1, 2
struct Student {
grades: Map<str, Grade>
favorite_subject: str
}
const { favorite_subject } = new Student { grades: Map::create(), favorite_subject: "programming" };
print(favorite_subject); // "programming"
Logic
if/else
In Lazy, if/else conditionals are expressions. All branches of the condition must return the same type, or none. Any expression can be used for the condition and the body - even other if expressions.
const a = 10;
if a > 10 print("a is more than 10")
else if a > 5 print("a is more than 5")
else print("a is less than 5")
enum Number {
Int: i32,
Float: f32
}
let enum_field = Number::Int(15);
// if "enum_field" is of type `Number::Float`, return the unwrapped float inside it, otherwise return 0.
let my_num = if let Number::Float(num) = enum_field enum_field else 0;
print(my_num == 0) // true
match
The match
expression is exactly the same as Rust's match
.
let my_num = Number:Float(3.14);
match my_num {
Number::Float(num) => print("Found float ", num),
Number::Int(num) => print("Found integer ", num),
// Specific cases
Number::Float(3.14) => print("Found pi!"),
// Guards
Number::Int(num) if num > 10 => print("Found integer bigger than 10"),
// Execute the same body for different expressions
Number::Int(3) | Number::Int(5) => print("Number is either 3 or 5"),
// Acts as an "else" - only executed when nothing above gets matched
_ => print("Weird...")
}
The contents of the match
expression are called arms
- each arm has a condition (which can be followed by a guard) and a body.
A condition can be:
- Enum variants (
Enum::variant
) - Literals (
"hello"
,'c'
,3
,45.3
,[1, 2, 3]
,true
,false
,none
) - Range iterators (
0..10
,5..=1000
,'a'..='z'
) - A list of expressions separated by
|
(1 | 5 | 7
) - if not all expressions return the same type, the value doesn't get unwrapped, but the body still gets executed.
Guards can be any expression.
A body can be any expression.
Loops
Loops are an imperative way to execute an expression multiple times. Lazy
provides two way to create an imperative loop: for
and while
.
For
In Lazy
, the for loop only has one variant - for...in
:
for i in 0..10 {
print(i);
}
// Prints 0, then 1, then 2... up to 9
The expression after in
can be any valid iterator - and an iterator satisfies the following type:
type Iterator<T> = { next: () -> T? } // Any struct with a "next" function is a valid iterator!
While
A traditional while
loop. The expression gets executed as long as the condition is true
.
while true {
// A never ending loop!
}
Breaking a loop
Both types of loops are expressions... but what do they return? By default, none
, unless you use the yield
keyword inside them. The yield
keyword stops the execution of the loop and returns the provided expression.
The following snippet checks if 20
is in vector
and if it is, the value
variable is set to true
, otherwise it's none
.
let vector = new Vec<i32>{ iter: 0..30 };
let value = for i in vector {
if i == 20 yield true
}
if value print("20 is in the vector!")
else print("20 is NOT in the vector!")
Promises
Async programming in Lazy
is made to be simple and easy:
import "std/http" as Http
main {
const res = await? Http::get("https://google.com");
print(res.body);
}
Creating promises
let prom = Promise::spawn(fn() -> Result<i32, none> Result::Ok(1 + 1));
let res = await? prom; // 2
Promises return a Result
, which is a special enum with two possible variants - T
, the actual return value of the promise, or an Error
:
Promise::spawn(fn() -> Result<none, str> {
Result::Error("This promise always returns an error :x");
});
A question mark (?
) can be put after the await
keyword to short-circuit the execution context if the promise returns an Error
.
Under the hood
Under the hood, Promise
s are handled by the tokio runtime.
Timers
Repeating a function
let counter = 0;
Promise::interval(fn() {
print(counter);
counter += 1;
}, 1m);
Timeout
Promise::timeout(fn() {
print("This will be executed in 5 seconds!");
}, 5s);
Stopping function execution
await Promise::block(5m);
Modules
Sometimes you want to split your code to multiple files - modules are just the thing you need!
Exporting
You can export the following things:
- struct declarations
- enum declarations
- static variables
- type aliases
export enum SongGenre {
Rock,
Pop,
Metal,
Jazz,
Rap,
HipHop
}
export struct Song {
const lyrics: str,
const genre: SongGenre
}
export static create_song = fn(lyrics: str, genre: SongGenre) -> Song {
new Song { lyrics, genre }
}
Importing
import { create_song } from "./path/to/songs"
main {
let bloodmoney = create_song("what do you believe in?", SongGenre::Metal);
}
as
binding
import * from "./path/to/songs" as Songs;
import { create_song as create_a_song } from "./path/to/songs";
main {
let bloodmoney = Songs::create_song("what do you believe in?", Songs::SongGenre::Metal);
}
Metaprogramming
Lazy allows you to create function macros which allow you to write cleaner and less-repetitive code. They're very similar to rust macros:
macro my_macro($a: ident, $b: expr) => {
$a[$b];
}
main {
}