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
- Clone the Scribe repository - scribe-lang/scribe
- Go to the cloned repository and create a
build
directory. - Go into the
build
directory and invoke the build commands:
cmake .. -DCMAKE_BUILD_TYPE=Release && make -j install
- This will install the compiler and its libraries in
$PREFIX_DIR
diectory (default:$HOME/.scribe
). - To run the compiler without specifying the path, add
$PREFIX_DIR/bin
to$PATH
. - 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:
- There must be a value expression for the variable
- 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 C | Scribe Equivalent |
---|---|
void | void |
bool | i1 |
char | i8 |
short | i16 |
int | i32 |
long int | i64 |
unsigned char | u8 |
unsigned short | u16 |
unsigned int | u32 |
unsigned long int | u64 |
float | f32 |
double | f64 |
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:
- std/vec,
- std/map, and
- std/linked_list
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 value1
. 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()
, andeachRev()
) must return an iteration structure (hereiter.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, andany
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
, whereE
is the iteration counter (index for a vector), andD
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.