Introduction

Welcome to the Scribe reference manual. Using this documentation, you will be able to understand and use the language for developing your own code.

This manual also includes the documentation for standard library, along with usage examples.

The Language

Scribe is a statically typed, fully compiled programming language that (currently) transpiles to C code, which is used to generate native binaries.

It provides modern features like:

  • Compile time code execution
  • Generics (templates) While providing the programmer with absolute control - there is no Garbage Collection, automatic memory management, or a runtime. In essence, the language can be considered an extension to C, with a different (but coherent) syntax.

Considering its features, Scribe finds most usefulness in areas related to systems software - developing high performance code with minimal memory footprint.

Now let's start with the installation of the Scribe compiler!

Scribe Compiler Installation

Prerequisites

  • CMake build system (>= 3.21.1)
  • C++17 standard conforming compiler (GCC, LLVM, etc.)

Steps

  1. Clone the Scribe repository - scribe-lang/scribe
  2. Go to the cloned repository and create a build directory.
  3. Go into the build directory and invoke the build commands:
cmake .. -DCMAKE_BUILD_TYPE=Release && make -j install
  1. This will install the compiler and its libraries in $PREFIX_DIR diectory (default: $HOME/.scribe).
  2. To run the compiler without specifying the path, add $PREFIX_DIR/bin to $PATH.
  3. That's it! The scribe compiler is now ready for use.

CMake Environment Variables

$CXX

This variable is used for specifying the C++ compiler if you do not want to use the ones auto decided by the script, which uses g++ by default for all operating systems except Android and BSD, for which it uses clang++.

For example, to explicitly use clang++ compiler on an ubuntu (linux) machine, you can use:

CXX=clang++ cmake .. -DCMAKE_BUILD_TYPE=Release && make install

$PREFIX_DIR

This variable will allow you to set a PREFIX_DIR directory for installation of the language after the build.

NOTE that once the script is run with a PREFIX_DIR, manually moving the generated files to desired directories will not work since Scribe's codebase uses this PREFIX_DIR internally itself.

Generally, the /usr or /usr/local directories are used for setting the PREFIX_DIR, however that is totally up to you. Default value for this is $HOME/.scribe.

Scribe compiler binary can be found as $PREFIX_DIR/bin/scribe.

An example usage is:

PREFIX_DIR=/usr/local cmake .. -DCMAKE_BUILD_TYPE=Release && make install

That concludes the compiler installation! Now you're ready to compile scribe programs, and we can start writing our first program: Hello World!

Hello World!

Here is a simple Hello World program written in Scribe.

let io = @import("std/io");

let main = fn(): i32 {
	io.println("Hello World");
	return 0;
};

That's it!

Let's break it down a bit.

Importing A Module

For dealing with any form of console I/O, we require the std/io module in our program. This module defines all the necessary functions and variables used to perform console I/O.

To import a module, we use the @import(<module name>) function and create a variable (io) from the result of that function. More about creating variables in the next chapter.

The Main Function

In any program, there must be a main function which is the entry point for the execution of the code. In other words, when you run the code, this is where the code logic will start executing from.

To create a function, we create a variable - name of the function, with the function expression as value.

The function expression is the fn keyword, followed by its signature (<arguments>): <return type>, followed by its body enclosed in braces ({...}), terminated by the semicolon (;).

This function returns an i32 - a 32 bit integer data.

Displaying Hello World

To display something on the console, the println(...) function inside the io module is called. To call a function residing inside a module, we use club the module name and function using the dot (.) operator.

Whatever we want displayed via println, we provide it as an argument to the function. For our purposes, we passed the string "Hello World".

Returning From Function

Since the main function returns an i32, we must return a value of that type. Here, we return 0 which for command line, basically means that the program exited successfully (exit status).

Conclusion

That's all! That's how you write a simple Hello World program in Scribe.

To run this program, save it in a file (say, hello.sc) and run the compiler on it using the following command: scribe hello.sc.

That will generate an executable binary named hello in the current directory, which can be run like this: ./hello.

That will produce the following output:

Hello World

Next, we will understand how variables in Scribe work.

Creating Variables

Variables are pretty much aliases that the programmers make for using memory to allocate some data. Obviously, we do not want, for our sanity's sake, to use memory addresses directly for accessing our data. That is really complicated, hard to remember, and an absolute nightmare to maintain. So instead, we assign these memory locations, containing our data, names which we can use in our programs as needed.

As such, variables are a crucial component of a programming language.

There are two ways to create variables in Scribe.

Definition

To define a variable in Scribe, the let <identifier> = <expression>; pattern is used.

The identifier will be the variable name, followed by the value expression after the assignment (=) symbol, terminated by a semicolon (;).

Since Scribe is a statically typed language, the type of each variable is necessary. However, in this (definition) format, the datatype is inferred. In other words, the compiler can deduce the type of the variable from the expression.

This is the syntax used to import the std/io module in the Hello World example.

For example:

let an_int = 20;                   // type: i32
let a_string = "this is a string"; // type: *const i8 (just like C)
let a_flt = 2.5;                   // type: f32 (float)

Declaration

To declare a variable, the let <identifer>: <type>; pattern is used.

As before, the identifier will be the variable name, followed by the datatype of the variable after a colon (:), terminated by a semicolon (;).

Here, we explicitly mention the datatype of the variable since there is no expression to deduce it from.

For example:

let an_int: u64; // type: u64 (unsigned 64 bit integer)
let a_flt: f64;  // type: f64 (64 bit float)

Notes

Both the styles can be merged as well, as long as the type and expression's type are same. The only exception is automatic type casting between primitive datatypes.

For example, if you want to create a variable with value 5 (which has type: i32), but of type: u64, you can do:

let u64int: u64 = 5;

Variable (Re)assignment

A variable won't quite be a "variable" if its value cannot be changed. Therefore, to change (or set) the value of a variable, you can use the variable name, followed by the assignment (=) symbol, followed by the value.

Do note that the datatype of the variable can never be changed after a variable is created. Hence, for (re)assignment, the datatype of the new value must be the same as the one used/inferred when creating the variable.

For example:

let a = 5;
a = 10; // valid
a = "a string"; // invalid

Similarly,

let a: u64;
a = 10; // valid
a = "a string"; // invalid
a = 5.0; // valid

Type Coercion

You may be wondering how is a = 5.0; valid. The datatype of a is u64, while that of 5.0 is f32.

The reason is that the compiler can automatically "coerce" between primitive (i1, i8, i16, i32, i64, u8, u16, u32, u64, f32, f64) types. In other words, the data from these types can be used with a value/variable of another of these types.

This does come at a loss of data itself (about which more can be found on the Internet), therefore one must be careful when writing code that can be automatically type coerced.

Variable Scope

The scope of a variable defines its lifetime - when is it created to when it is destroyed. Scribe, like many languages, uses braces ({ and }) to define blocks of code. Any variable has its lifetime bound to this block - it cannot be used outside this block.

For example:

let a = 5;

This is globally scoped - not contained within any block. Therefore, this variable can be used anywhere after its creation.

Another example:

let main = fn(): i32 {
	let a = 5;
};

Here, the variable a can be used anywhere within the braces of the main function (and of course, after the creation).

Scribe also supports variable shadowing. That is, if there is a variable in a parent block, a new variable can be created of the same name, within a new block, inside the parent block.

For example:

let main = fn(): i32 {
	let a = 5;
	{
		let a = "a string";
	}
};

The above code is valid. Within the main function's block, the integer variable a is usable. And within the block inside the main function, the string variable a is usable.

Variable Names

One last important thing about variables is their name. Scribe defines specific rules based on which you can name variables, quite similar to most other languages. These rules are that variable names:

  • Must begin with an alphabet (irrelevant of the case) or underscore
  • Can contain numbers anywhere except the first character
  • Cannot contain any symbol other than alphabets, numbers, and underscores.

Variable Type Modifiers

Type modifiers alter the working of a variable - what they store, how they work, etc.

There are 4 type modifiers in Scribe:

  • Static
  • Reference
  • Constant
  • Comptime

Static

The static modifier allows the variable to be stored in global storage - the variable is created only once. If such a variable is present inside a function, the variable will be initialized only once and it will maintain its value across function calls.

For example,

let io = @import("std/io");

let f = fn(): i32 {
	let static x = 5;
	return x++;
};

let main = fn(): i32 {
	io.println(f()); // prints 5
	io.println(f()); // prints 6
	io.println(f()); // prints 7
	return 0;
};

Reference

Reference variables are the same as in C++ - they are references to other variables. In other words, reference variables can be considered as aliases to other variables.

These variables are created by using & with the type.

For example,

let f = fn(data: &i32) {
	data = 10;
};

let main = fn(): i32 {
	let a = 5;
	f(a);
	// now a is 10
	return 0;
};

Constant

Variables, in general, can be modified. However, there are times when the programmer does not want a variable to be modifiable - it must stay constant. That's what the constant modifier allows.

Constant variables, once created, can never be modified again. Attempting to modify them is a compilation error.

For example,

let main = fn(): i32 {
	let const i = 10;
	i = 20; // error: i is a constant
	return 0;
};

Comptime

Scribe contains a pretty nifty and useful feature - comptime variables. A comptime variable is special in the sense that the value of such a variable is evaluated during the code compilation. Hence, some conditions apply for a variable to be allowed to be comptime:

  1. There must be a value expression for the variable
  2. The value expression must be able to be evaluated during compile time itself - it cannot contain data that cannot be computed during compilation

For example,

let comptime a = 50; // valid - 50 is a value which is known by the compiler
let f = fn(x: i32): i32 {
	return x * 5 + 25;
};

let comptime b = f(10); // valid - function call is provided a value known by the compiler, and everything inside the function body is computable by the compiler

let io = @import("std/io");

let f2 = fn(x: i32): i32 {
	io.println(x);
	return x * 5 + 25;
};

let comptime c = f2(10); // invalid - io.println() does not work at compile time

let arr = @array(i32, 10);

let comptime x = arr[1]; // valid - @array() creates an array and initializes it to zero at compile time

Conclusion

Well, that is basically how variables, their reassignment, and their scopes, work. Not much to learn or understand and pretty easy - which is the goal!

Next, we'll understand the concept of datatypes and see some of the fundamental (primitive) datatypes in Scribe.

Datatypes

Any data that exists must have a type associated with it, and since Scribe is statically typed, these datatypes must be known when the program is compiled.

Datatypes are abstractions over binary sequences that define what kind of data a variable contains, and how that data is represented in memory.

If you have any prior experience in programming, you probably know datatypes quite well.

Core datatypes in Scribe are:

Types in CScribe Equivalent
voidvoid
booli1
chari8
shorti16
inti32
long inti64
unsigned charu8
unsigned shortu16
unsigned intu32
unsigned long intu64
floatf32
doublef64

Similar to C, Scribe contains pointers as well.

And the string literals are represented as *const i8 (equivalent to const char * in C).

Let's dive a bit into the core datatypes.

Core Datatypes - Primitive Types

Integers

Scribe contains a total of 9 integer types varying in bit count and signed vs unsigned.

The signed variants are: i1, i8, i16, i32, and i64. The unsigned variants are: u8, u16, u32, and u64.

Depending on requirement, anyone of these can be used.

For example,

let a = 32;      // type: i32
let b: u64 = 64; // type: u64
let c = true;    // type: i1

Floating Points

There are 2 floating-point types in Scribe - f32 and f64.

For example,

let a = 3.2;      // type: f32
let b: f64 = 6.4; // type: f64
let c = 3.0;      // type: f32

The decimal point (.) is necessary to differentiate between an integer and a floating-point number.

Booleans

In Scribe, booleans are represented using the i1 type. bool is available as well and is an alias to i1.

For example,

let a = true;        // type: i1
let b: bool = false; // type: i1
let c: i1 = true;    // type: i1

Characters

Characters are nothing but i8 in Scribe. However, similar to C, characters are represented within single quotes ('). These characters are byte-sized and no more than one character can be present within the single quotes.

Therefore, unicode characters must be defined as strings (within double-quotes).

For example,

let a = 'a'; // type: i8

Strings

The fundamental "string" in Scribe is the same as in C - pointer to i8 (char). All string literals have the type *const i8 (C's equivalent to const char *).

For example,

let a = "Hello World"; // type: *const i8

Scribe, unlike C however, also contains a String type in standard library. The String type and C-style strings can be converted to each other using provided functions (more in the String standard library chapter of this manual).

Enums

Similar to C, Scribe contains enums which have integer values. However unlike C, enum variables cannot be used by themselves. They must be preceded by the enum name and dot (.).

This allows for having same enum tags across multiple enums without them clashing with each other.

For example,

let A = enum {
	ZERO,
	ONE,
	TWO
};

let B = enum {
	ZERO, // no clash here
	FIRST,
	SECOND,
};

let zero_in_a = A.ZERO;
let zero_in_b = B.ZERO;

let main = fn(): i32 {
	let t = A.ONE == B.FIRST; // true - both contain value 1
	let f = A.ONE == B.ZERO;  // false - 1 != 0
	return 0;
};

Structures

Scribe provides structures as a data structure to pack multiple (existing) types/structures into a single unit. Similar to C, structure members are accessed using the dot (.) operator.

To instantiate a structure, the structure is "called" using braces ({...}) with the member values as arguments.

For example,

let A = struct {
	i: i32;
	f: f64;
};

let B = struct {
	u: u64;
	a: A; // B contains variable a, which is of type structure A
};

let main = fn(): i32 {
	let a = A{3, 2.5};
	let b = B{1, a};
	let c = B{10, A{3, 5.0}};
	let p = a.i + b.u;
	let q = a.f + b.a.f;
	let r = c.u + c.a.f;
	return 0;
};

Unlike C however, pointers to structures are deduced internally, so even for structure pointers, the dot (.) operator applies (no arrow (->) or dereference (*) operator required). We will see this behavior when we understand pointers in Scribe.

Scribe also allows for "member functions" for structures. However, the term "member functions" is an incorrect term for Scribe because these functions are not defined inside the structure itself. In this language, functions can be associated to structures, hence referred to as "associated functions". These functions work on structures, but can be defined anywhere after the structure itself is created.

For example,

let A = struct {
	i: i32;
	f: f64;
};

let getI in A = fn(): i32 {
	return self.i; // every associated function will have a "self" variable which is a reference to the struct (instance) to which the function is associated
};

let main = fn(): i32 {
	let a = A{1, 2.5};
	let x = a.getI(); // x = 1
	return 0;
};

Arrays

Scribe provides the ability to create arrays - a contiguous sequential collection of data of a single structure/type. The array size must be known at compile time, that is, the array length must be known when writing the code itself.

For example,

let arr1 = @array(i32, 10);     // 1D (size: 10) array of type i32
let arr2 = @array(i32, 10, 10); // 2D (size: 10x10) array of type i32

let A = struct {
	i: i32;
	f: f64;
};

let arr3 = @array(A, 10); // 1D (size: 10) array of type structure A
let data1 = arr3[1].i;    // data in arr3's member 'i' at the first index

Scribe does not have a specific array type notation (usually something like int[5] in most languages). Instead, in Scribe, arrays are passed around as pointers themselves.

An important thing to note about arrays in Scribe is that they are always initialized to zero values. That is, unlike C, the array values are never undefined.

Pointers

Pointers allow the use of heap memory to dynamically (at runtime) allocate memory which can be of variable size. These variables usually store either memory addresses to other variables, or the memory address to some data on the heap. They may also not point to any data - when their value is zero (nil).

Note that any memory allocated during runtime by the program must be deallocated by the program as well.

For example,

let c = @import("std/c"); // contains C functions

let main = fn(): i32 {
	let a = 5;
	let ptr_a = &a;
	let data_a = *ptr_a;
	*ptr_a = 10; // now a is also 10

	let arr = mem.alloc(i32, 10); // allocate memory equal to 10 i32's at runtime
	arr[1] = 5; // set value 5 at index 1 of arr
	mem.free(i32, arr); // deallocate memory which was allocated by malloc
	return 0;
};

Special Types

Scribe contains some special types for specific use cases. These are a bit advanced and must not be used unless needed (you will know if they're needed).

These types are used solely in function signatures. As such, they are not used anywhere else.

Type

type is a special type, a variable of which can contain a type. This is all compile time only and no code is generates for this. This is used with generics (more on generics later) to pass datatypes as argument. A variable of type type must be declared comptime.

This is how mem.alloc() function, used above, works. We can pass i32 type to malloc because its signature's first parameter is of type type.

For example,

let f = fn(comptime T: type, data: T): T {
	return data * data;
};

let main = fn(): i32 {
	let p = f(i32, 10);  // p is of type i32, with value 100
	let q = f(f32, 2.5); // q is of type f32, with value 6.25
	return 0;
};

Any

any, as you may have guessed, allows variable of any type to be passed to a function. If used as return type, it allows the compiler to deduce what type is being returned by the function through the return statements present inside the function.

let f = fn(data: any): any {
	return data * data;
};

let main = fn(): i32 {
	let p = f(i32, 10);  // p is of type i32, with value 100
	let q = f(f32, 2.5); // q is of type f32, with value 6.25
	let comptime a = f(i32, 10);  // a is of type i32, with value 100, set at compile time
	let comptime b = f(f32, 2.5); // b is of type f32, with value 6.25, set at compile time
	return 0;
};

Conclusion

Well, this was a bit long, but here we cover all the core datatypes in Scribe. There is a lot more to these datatypes but that is out of scope of this manual.

Take your time to understand these datatypes as they are incredibly useful and important for any programming language. Now, we will move on to functions.

Functions

Functions are groups of instructions, combined to perform specific tasks. Using functions, we can avoid having to repeat a task's code over and over again whenever we want to perform the task.

Functions are quite pervasive in programming languages - there would be rarely any programming language without these.

When we create a function with its group of instructions, it is called function definition. The group of instructions/statements is called function body. And, the usage of the function, it is called a function call.

We may need to provide functions with some additional data, or retrieve some data from it. For that, we use function arguments and function return values respectively.

Scribe Functions

There are a variety of functions in Scribe. Some of them are:

  • Simple Functions
  • Intrinsic Functions
  • Associated Functions
  • Callback Functions
  • Variadic Functions

Let's go over each of them.

Simple Functions

These are the most basic functions in Scribe. The main function, for example, is actually a simple functions. Like any other function, these may or may not take arguments and which may or may not return data.

For example,

let io = @import("std/io");

let greet = fn(whom: *const i8) { // simple function - takes a *const i8 as argument, returns nothing (void)
	io.println("Hello ", whom);
};

let main = fn(): i32 { // simple function - takes no argument, returns an i32
	greet("Electrux");
	return 0;
};

Intrinsic Functions

These functions are built into the compiler and cannot be created in Scribe code. They never emit any code and perform their task while the code is being compiled.

We have been using intrinsic functions all along - @import() is an intrinsic function which instructs the compiler to import a source file during compilation.

As you may have guessed, intrinsic functions are always prepended by @ when they are called.

Another intrinsic that we have used is the @array() function with which array creation is possible.

For example,

let io = @import("std/io");
let arr = @array(i32, 10);

Associated Functions

These functions are associated to a structure or type. Yes, functions can be associated to built-in types (like i1, i32, f32, etc.) as well, except for pointer and array types.

Such functions work on the instance of the structure/type they are associated to.

Internally, these functions are provided a self argument which is a reference to the instance itself.

For example,

let io = @import("std/io");

let A = struct {
	i: i32;
};

let getI in A = fn(): &i32 { // return reference to the member "i"
	return self.i;
};

let disp in i32 = fn() {
	io.println(self);
};

let main = fn(): i32 {
	let a = A{1};
	let p = a.getI(); // p is now a reference to a.i because getI() returns a reference
	p = 20;
	p.disp();         // displays 20
	a.getI().disp();  // displays 20 - p was a reference to a.i
	a.getI() = 10;    // works because getI() returns a reference
	a.getI().disp();  // displays 10
	return 0;
};

Associated functions may be created anywhere in the code as long as they are created before their usage.

There are times when you want to created associated functions on constant instances of types. For example, a toStr() function for an i32 does not require the i32 variable to be modifiable, and this function should be usable on const i32 as well.

Scribe can handle this situation. A specification detail of Scribe is that the type/struct after in in a let statement is not a type. It is a type expression.

Therefore, for our const usecase, all you need to do is create the function as:

let toStr in const i32 = fn() {
	// ...
};

Quite convenient, right?!

Callback Functions

Callback functions are simple functions, but they are passed around to other functions so that the other function can call them.

That may sound weird, but this is a common concept which is used in many languages including Javascript (most notably), C, and C++.

For example,

let io = @import("std/io");

let add5 = fn(data: i32): i32 {
	return data + 5;
};

let sub5 = fn(data: i32): i32 {
	return data - 5;
};

let f = fn(data: i32, cb: fn(x: i32): i32) {
	io.println("Result is: ", cb(data));
};

let f2 = fn(data: i32, cb: any) {
	io.println("Result is: ", cb(data));
};

let main = fn(): i32 {
	f(10, add5); // Result is: 15
	f(10, sub5); // Result is: 5
	f2(10, sub5); // Result is: 5
	return 0;
};

Note that the function to which callback is passed must have the callback argument with same function signature as the callback function itself.

The special type any can be used if desired, but that is more error prone since anything can be provided for any instead of just functions with specific signatures.

Variadic Functions

These functions are special in that they have a parameter which is variadic - the function call can have infinite number of arguments in place of the variadic parameter.

There can be only one variadic parameter in a function and it is declared variadic by prepending triple dots (...) with its type.

For anyone coming from C, the printf() function in C is a variadic function.

Unlike C however, Scribe's variadic parameters do not require any type casting and the compiler always knows the types stored in the variadic parameter.

Combined with the any type, variadic parameters can be used to take any provided argument without limitation on the type of the argument. This makes variadic functions quite powerful and very useful. The io.println() function that we have been using, for example, is a variadic function - it can take an infinite list of comma separated arguments.

For example,

let sum = fn(args: ...i32): i32 { // args is a variadic which accepts all arguments of type i32
	let comptime len = @valen(); // @valen() is an intrinsic which provides the number of variadic arguments (>= 0) with which this function is called
	let sum = 0;
	inline for let comptime i = 0; i < len; ++i { // we will learn more about inline loops later; do note that here 'inline' and 'comptime' must be present
		sum += args[i];
	}
	return sum;
};

let main = fn(): i32 {
	let s1 = sum(1); // s1 = 1 at runtime
	let comptime s2 = sum(1, 2, 3); // s2 = 6 at compile time
	return 0;
};

Variadic arguments can also be "unpacked" and passed to other variadic functions just by using the variadic parameter name.

For example,

let sum = fn(args: ...i32): i32 {
	let comptime len = @valen();
	let sum = 0;
	inline for let comptime i = 0; i < len; ++i {
		sum += args[i];
	}
	return sum;
};

let pass = fn(data: ...i32): i32 {
	return sum(data, 5); // unpacks data and passes all the provided variadic arguments, with 5 at the end, to sum()
};

let main = fn(): i32 {
	let s1 = pass(1); // s1 = 6 at runtime
	let comptime s2 = pass(1, 2, 3); // s2 = 11 at compile time
	return 0;
};

You can also perform your own type check on variadic arguments at compile time to ensure the given arguments' types are one of specified types.

Since these are completely compile time, they do not generate any code at runtime and hence will not impact performance.

For example, to limit a variadic function to work with types i32 and i64 only,

let sum = fn(args: ...any): i32 {
	let comptime len = @valen();
	inline for let comptime i = 0; i < len; ++i {
		inline if !@isEqualTy(args[i], i32) && !@isEqualTy(args[i], i64) {
			@compileError("Expected argument type to be either i32 or i64, found: ", @typeOf(args[i]));
		}
	}
	let sum: i64 = 0;
	inline for let comptime i = 0; i < len; ++i {
		sum += args[i];
	}
	return sum;
};

let main = fn(): i32 {
	let s1 = sum(1);
	let comptime s2 = sum(1, 2, 3, 4.5); // compilation fails - 4.5 is neither i32 nor i64
	return 0;
};

For those who are accustomed to programming and variadics, feel free to check out the standard library's std/io module, which contains multiple variadic functions.

Conclusion

Well, that is fundamentally how functions in Scribe work. It's recommended to write some sample programs to understand and ease into them.

Next up, we'll be looking at Generics.

Generics

Generics, also known as templates, are special structures and functions which contain one or more variables with placeholder types. These placeholder types are replaced by actual types when the generic structure/function is created/used.

In modern programming, generics are one of the most useful features as they allow a programmer to write "generic code" which can be used with different types by replacing the placeholders.

Generics are the reason why data structures like vectors (dynamically sized arrays), maps (dictionaries), etc. are available in standard libraries of languages like C++. Since they use generic datatypes, they can work with any actual type the programmer requires.

Let's understand generics and their use.

Generic Structs

These structures contain one or more variables which have a "template"/generic type.

For example,

let GenA = struct<T> {
	data: T;
	ptr: *T
};

These structures cannot be instantiated directly. Instead, they must first be specialized to replace generic types with the actual types, and then initialized like any other structure.

For example,

let GenA = struct<T> {
	data: T;
	ptr: *T
};

let main = fn(): i32 {
	let a = GenA(i32){5, nil};
	return 0;
};

Here, we specialize GenA with i32 as the generic's replacement type, and then instantiate that with 5 and nil for data and ptr respectively.

Generic Functions

These functions contain one or more of either any type, or type type as a parameter.

With any, as previously described, any argument can be provided.

With type however, a specific type is utilized which can also be used elsewhere - say, as return type, variable declaration type, etc.

Combined with generic structures, we can also use a variable with type type to specialize a structure.

For example,

let GenA = struct<T> {
	data: *T;
};

let new = fn(comptime T: type): GenA(T) {
	return GenA(T){nil};
};

let main = fn(): i32 {
	let a = new(f32); // returns an instance of GenA, specialized with f32
	// a.data is of type *f32
	return 0;
};

All functions associated to generic structures are also generic functions themselves. Scribe provides the generic type as a member of the structure instance in these functions, accessible using self.

For example,

let GenA = struct<T> {
	data: *T;
};

let set in GenA = fn(newdata: *self.T): &self { // self can also be used as return type, which will become &GenA(self.T)
	self.data = newdata;
	return self;
};

let main = fn(): i32 {
	let a = GenA(i32){nil};
	let p: *i32 = nil;
	let q: *i32 = nil;
	a.set(p).set(q); // chaining is possible since set() returns &self
	return 0;
};

In Scribe, many data structures are made using generic structures and functions like:

Note that the generics are used and eradicated internally by the scribe compiler. That is, the generated C code (and subsequently the executable binary) will have no information of generics whatsoever.

It is also recommended to check out the examples and standard library of Scribe to understand more about generics if you understand the concepts well, as they can be used in a variety of ways and have their own nuances.

Conclusion

Here, we have learnt about generics and their usage in Scribe. We shall now move on to conditionals.

Conditionals

Conditionals are decision making constructs in a programming language. Basically, if we want to perform some action based on specific condition or criteria, we use conditionals.

In Scribe, there are two variants of conditionals:

  • Simple Conditionals
  • Inline Conditionals

Simple Conditionals

These are the usual conditionals that exist in most programming language. In Scribe, the following syntax is followed:

if <expression> {
	// do this if expression yields a truthy value
} elif <expression> {
	// do this if the first if fails, but elif's expression yields a truthy value
} else {
	// do this if none of the if's and elif's yield a truthy value
}

The elif and else sections are optional and may be skipped as required.

A "truthy value" is one of:

  • boolean == true
  • integer != 0
  • float != 0.0
  • pointer != nil

For example,

let io = @import("std/io");

let main = fn(): i32 {
	if true { // boolean true is a truthy value, therefore the following block is executed
		io.println("This is displayed");
	}
	let ptr: *i32 = nil;
	if ptr { // nil is not a truthy value, therefore the else block is executed
		io.println("Pointer points to some data");
	} else {
		io.println("Pointer points to no data");
	}
	let a = 2;
	if a == 1 { // this condition fails therefore the following block is not executed
		io.println("a is 1");
	} elif a == 2 { // this condition succeeds, therefore the following block is executed
		io.println("a is 2");
	} else {
		io.println("a is neither 1 nor 2");
	}
	return 0;
};

Inline Conditionals

These are special conditionals that are evaluated at compile time. This means that the compiler will execute the conditional during compilation and whichever section yields a truthy value, that section will be left in the code. The other sections of the conditional will be removed completely.

Also, if none of the sections evaluate to a truthy value, the entire conditional will be removed.

Note that the conditional's block will not executed like it is during runtime. Instead, the block will be placed as long as its conditonal yields a truthy value.

For example,

let A = struct {
	data: i32;
};

let getData in A = fn(): i32 {
	return self.data;
};

let get = fn(from: &any): i32 {
	inline if @isEqualTy(from, A) {
		return from.getData();
	} elif @isEqualTy(from, i32) {
		return from;
	} else {
		@compileError("Cannot return i32 from data of type: ", @typeOf(from));
	}
};

let main = fn(): i32 {
	let a = A{5};
	let b = 20;
	let c = "a string";
	let a1 = get(a); // calls a.getData()
	let b1 = get(b); // returns b
	let c1 = get(c); // compilation fails (@compileError() is called) - c is neither an instance of A nor an i32
	return 0;
};

As visible from the above example, inline conditionals can be quite powerful - they allow decision making based on compile time known information. The code is valid because whichever condition does not hold true, the block for that conditional will be erase altogether. Therefore, compiler will not complain about the lack of, say, getData() associated function not existing, when an i32 is passed to the function.

Conclusion

Hence, we understand how conditionals in Scribe work and what all they can do.

Next, we will understand about loops.

Loops

Loops are sequences of code that run over and over again, usually, as long as some condition is met. They, like functions, help a lot in reducing repetitive code.

For example, if we want to make a multiplication table of 12 from 1 to 10, would we write 10 statements by hand? It would look something like this:

let io = @import("std/io");

let main = fn(): i32 {
	io.println("12 x 1 = ", 12 * 1);
	io.println("12 x 2 = ", 12 * 2);
	io.println("12 x 3 = ", 12 * 3);
	io.println("12 x 4 = ", 12 * 4);
	io.println("12 x 5 = ", 12 * 5);
	io.println("12 x 6 = ", 12 * 6);
	io.println("12 x 7 = ", 12 * 7);
	io.println("12 x 8 = ", 12 * 8);
	io.println("12 x 9 = ", 12 * 9);
	io.println("12 x 10 = ", 12 * 10);
	return 0;
};

That works, sure. But for sure it isn't convenient! And this is just for tables from 1-10. What about 1-100, or 1-1000? That's 1000 lines of code which is incredibly hard to maintain.

That's where loops come in. The above code, using loops, could simply be written as:

let io = @import("std/io");

let main = fn(): i32 {
	for let i = 1; i <= 10; ++i {
		io.println("12 x ", i, " = ", 12 * i);
	}
	return 0;
};

And if we want to do this for, say, 1000, all we have to do is change the i <= 10 condition to i <= 1000! That's all!

That's a really basic example of loops, but it should paint the picture of their fundamental purpose.

Each execution of the loop block is called loop iteration. As such, the above loop had 10 iterations as the loop block was executed 10 times.

There are special statements that can be used with loops:

  • continue - This causes a loop's current iteration to be "skipped" from where the continue statement is present.
  • break - This causes a loop to stop executing from where the break statement is present.

In Scribe, there are 3 varieties of loops:

  • For Loops
    • Simple For Loops
    • Inline For Loops
  • While Loops
  • For-each Loops

For Loops

Similar to conditionals, there are two types of for-loops:

  • Simple For Loops
  • Inline For Loops

Simple For Loops

These are the most basic and standard loops that exist throughout various programming languages. These loops consist of 4 components:

  • Initialization - This component is used to initialize the loop, often by creating a variable. Like in the above example, we initialized i with value 1. This component is executed just once, right before the loop starts.
  • Condition - This defines how many times the loop must be run. Before each loop iteration, this condition is checked. If the condition is true, the iteration occurs, otherwise the loop is stopped.
  • Increment/Decrement - This This component allows for modification in some value - often to update the variable that is being looped through (above, ++i) after each execution of the loop block.
  • Loop Block - This is the body of the loop. Whatever we want executed multiple times comes here.

Aside from loop block, all the other components are optional and we can also skip them all should we want to, which will make for an infinite loop - a loop that never ends.

The loop block can also be empty if there is nothing to be done inside the loop.

Needless to say, the loop we wrote for generating table of 12 was a simple for loop.

For example,

let io = @import("std/io");

let main = fn(): i32 {
	let i = 0;
	for ; ; {
		if i >= 10 { break; }
		if i == 5 { continue; }
		io.println(i);
		++i;
	}
	return 0;
};

The above loop will print all numbers from 0-9 (inclusive), except 5.

Inline For Loops

Similar to inline conditionals, Scribe supports inline for loops which are executed at compile time. It requires that all the components of the loop, except the loop block, must be evaluable at compile time.

Note that the loop block will not executed like it is during runtime, but instead, the block will be repeated as many times as the loop condition holds true. Therefore it is unwise to make large inline for loops as they will increase code size, increase compilation time, and most likely reduce code performance as well. An infinite inline for loop will cause the compiler to never finish compiling (and if not stopped, cause the system to run out of memory).

Functions like io.println() are able to exist in Scribe userland (without special compiler magic) code because of variadic parameters, inline for loops, and inline conditionals.

As we have seen in the Functions chapter of this manual, inline for loops can be used to iterate over a variadic parameter to work with each of the provided argument.

For example, a println() function can be written as:

let c = @import("std/c"); // imports functions from C
let string = @import("std/string"); // contains string.String and associated functions for integer to string (<int>.str())

let println = fn(data: ...&const any): i32 { // takes variadic number of arguments each of type &const any (any const reference), and returns an i32
	let comptime len = @valen();
	let sum = 0;
	inline for let comptime i = 0; i < len; ++i {
		inline if @isCString(data[i]) {
			sum += c.fputs(data[i], c.stdout);
		} elif @isCChar(data[i]) {
			sum += c.fputc(data[i], c.stdout);
		} elif @isEqualTy(data[i], string.String) {
			sum += c.fputs(data[i].cStr(), c.stdout);
		} else {
			let s = data[i].str();
			defer s.deinit();
			sum += c.fputs(s.cStr(), c.stdout);
		}
	}
	sum += c.fputc('\n', c.stdout);
	return sum;
};

let main = fn(): i32 {
	println(); // prints a newline ('\n') character
	println(5); // none of inline if's are true, therefore else is used
	println("Hello ", 1); // first argument is a cstring, second is an integer
	return 0;
};

In the function, a compile time iteration (using inline for) is done over each of the variadic parameter's provided argument. Based on the type of that argument, we determine which function to call in order to display the data.

Note that the iteration variable (here, i) must be created as comptime or the results may be undefined.

While Loops

Unlike for loops which consist of 4 components, while loops consist of just 2 - loop condition and loop body. In a sense, these can be considered a subset of for loops, simply providing a cleaner approach for creating a loop when only the condition is involved.

For example,

let io = @import("std/io");

let main = fn(): i32 {
	let i = 0;
	while i < 10 {
		if i == 5 { continue; }
		io.println(i++);
	}
	return 0;
};

There is no inline variant for while loops.

For-each Loops

These loops are generally used to list over items in a data structure. At the end of the day, they are syntactic sugar which are transformed by the compiler into simple for loops.

For example,

let io = @import("std/io");
let vec = @import("std/vec");

let main = fn(): i32 {
	let v = vec.new(i32, true);
	defer v.deinit(); // we'll come to defer in the next chapter
	for let i = 0; i < 10; ++i {
		v.push(i);
	}
	for e in v.each() { // iterates over each element, the reference of which is in variable 'e'
		io.println(e);
	}
	for e in v.eachRev() { // iterates over each element, in reverse
		io.println(e);
	}
	for e in v.each() {
		e = e + 1; // since e is a reference to an element in the vector, it can be modified
	}
	io.println(v);
	return 0;
};

There are some requirements that must be met in order to use for-each loops on your own structure (type):

  • The iterator generation function (here, each(), and eachRev()) must return an iteration structure (here iter.Iter, see std/iter) that contains the following functions:

    • begin(): E
    • end(): E
    • next(E): E
    • at(E): &any

    Where, E is the iteration counter, and any is the data (usually reference) that should be stored in the for each iteration variable (e).

  • The structure (type) must contain a function with signature as at(E): D, where E is the iteration counter (index for a vector), and D is the returned data against the iteration counter (element reference for a vector).

Once these conditions are met, any custom datatype (structure) can be used with a for-each loop.

As with while loops, there is no inline variant for for-each loops.

Conclusion

Here we learn about the various types of loops in Scribe, and their usage. Next up, we will understand an interesting Scribe feature - defer.

Defer

A common problem programmers face with C is that since memory allocation is done manually, it must later be deallocated manually as well. We often forget to do that, which results in memory leaks.

Another similar issue stems when there are multiple exists (returns) from a function. If the deallocation must be done at the end of the function, it would either have to be done before all returns, or there would be multiple gotos to a single tag, instead of multiple returns, where the deallocation will be done and finally function will be exited (returned).

Both of the approaches are quite cumbersome and annoying to work with.

Therefore, Scribe provides a defer language keyword which is followed by an expression that must be executed before the function exists (returns). This defer may be placed anywhere in the code and its expression is guaranteed to be called before the function returns (does not include crashes).

As such, a programmer would generally write a defer right after the allocation, therefore not having to remember to deallocate later on, and reducing code redundancy when multiple returns are involved.

It also makes reading code easier in the future as one can simply see the allocations and their respective defers just after them to ensure all data is being allocated and deallocated correctly.

For example,

let c = @import("std/c");

let main = fn(): i32 {
	let arr1 = mem.alloc(i32, 10); // allocate 10x i32's
	let arr2 = mem.alloc(f64, 20); // allocate 20x f64's
	defer mem.free(i32, arr1);
	defer mem.free(f64, arr2);

	if true {
		// both frees would be added here automatically
		return 1;
	}
	// both frees would be added here automatically
	return 0;
};

Note that the deferred expressions are added in the reverse order of their usage. In the above example, the mem.free()'s will be added before the returns such that arr2 is free'd first.

defer is not limited to a function. It actually works on scopes, so something like this is correct too:

let c = @import("std/c");
let io = @import("std/io");

let main = fn(): i32 {
	for let i = 0; i < 10; ++i {
		let arr = mem.alloc(i32, i + 1);
		defer mem.free(i32, arr);
		let addr = @as(u64, arr);
		io.println("Allocated address: ", addr);
		// mem.free() is called here
	}
	return 0;
};

Conclusion

Defer is an interesting and useful construct which provides a balance between the complexities of C++'s RAII, and C's error prone ways.

Credits to the Zig programming language which uses defer as well, from where I so shamelessly copied the concept.

For now, this concludes the main Scribe features. Feel free to check out the standard library documentation which may come in quite handy.