-
Notifications
You must be signed in to change notification settings - Fork 29
CBL
CBL is a high level programming language with similarities to C++. It has special syntax to make common tasks easier to write.
CBL is designed so the end user never has to write commands directly, the compiler is smart enough to figure out how to take an expression and generate a sequence of commands to implement it.
See the CBL API page for the API documentation. This also covers the built-in types.
The syntax/grammar for CBL is listed on the CBL Syntax page.
Perhaps a good way to introduce CBL is with some examples to demonstrate the various features.
Not everything is covered here, see the CBL Syntax page for a more detailed look at the language's grammar.
A simple function is defined like so:
void main() {
}
This creates an empty file main.mcfunction
in the datapack.
Lets do something a bit more interesting.
int add(int a, int b) {
return a + b;
}
This function takes two integer parameters, sums them, then returns the result. The mechanics for how parameters and return variables are handled is described in Command IR.
Now lets declare some variables and call add
.
int add(int a, int b) {
return a + b;
}
void main() {
int i = 2;
int j = 3;
int k = add(i, j);
}
What if we wanted to share the result in a global variable? Just define the variable at the global scope:
int add(int a, int b) {
return a + b;
}
int k;
void main() {
k = add(2, 3);
}
CBL has support for arrays. Here we create an int
array of length 4, store some numbers in it and sum up the total:
void main() {
int arr[4];
int i;
for (i = 0; i < 4; i++) {
arr[i] = i * 2;
}
int sum = 0;
for (i = 0; i < 4; i++) {
sum += arr[i];
}
}
There are two "modes" of parameter passing: pass by value and pass by reference.
Passing by reference allows the original variable to be mutated after calling a function. For example
void changeMe(int &x) {
x = x * 2;
}
void main() {
int value = 10;
changeMe(value);
// value is now 20
}
So far, we've just been dealing with the int
type, but what about more complex types?
The type
declaration allows defining custom structured types:
type MyType {
int x;
int y;
}
MyType
is a type consisting of two integers x
and y
. It can be passed into functions just like any other type.
int sum(MyType mt) {
return mt.x + mt.y;
}
Note that by default, parameter passing is by-value, so the following will not mutate myVar
:
void changeX(MyType mt, int x) {
mt.x = x;
}
void main() {
MyType myVar;
changeX(myVar, 10);
}
changeX
needs mt
to be pass-by-reference:
void changeX(MyType &mt, int x) {
mt.x = x;
}
Types aren't limited to just storing data. Functions can be bundled too, OOP style.
type MyType {
int x;
int y;
void changeX(int x) {
this.x = x;
}
}
void main() {
MyType myVar;
myVar.changeX(5);
}
Now there exists a this
variable which implictly references the current instanciation of MyType
.
Internally, the changeX
function is rewritten to be just like the earlier example, passing MyType
by reference: void changeX(MyType &this, int x)
.
Want to define changeX
outside of the type declaration?
type MyType {
int x;
int y;
void changeX(int x);
}
void main() {
MyType myVar;
myVar.changeX(5);
}
void MyType::changeX(int x) {
this.x = x;
}
The function body for changeX
needn't be defined prior to main()
using it, only a declaration is needed.
If changeX
is never defined, it is exported as an unresolved symbol. Trying to run the linker will fail due to an unresolved symbol. The implementation of changeX
could be defined in a separate file, passing the other file to the linker will resolve the symbol.
So far, we've been using the implicitly defined default constructor. However, perhaps we want to control object construction.
type MyType {
int x;
int y;
constructor() {
this.x = 1;
this.y = 1;
}
void changeX(int x);
}
Note that the way this constructor is defined, x
and y
are first default-constructed, then copy-assigned a new value. To call the int
copy-constructor directly, the following syntax is used:
type MyType {
int x;
int y;
constructor(): x(1), y(1) {
}
void changeX(int x);
}
Perhaps MyType
requires a parameter to it's constructor:
type MyType {
int x;
int y;
constructor(int valueForX): x(valueForX), y(1) {
}
void changeX(int x);
}
Without a default constructor (one that takes no arguments), it is not possible to create an instance of MyType
without passing valueForX
.
void main() {
MyType mt; // Error, valueForX not given
MyType mt(2); // This is fine
}
By default, types have a copy-constructor: a constructor which takes the type as input and copies to itself.
type MyType {
int x;
int y;
// This constructor is added automatically if a user-defined copy-constructor does not exist.
constructor(MyType other) {
this.x = other.x;
this.y = other.y;
}
}
It is possible to create function overloads that differ by parameters only:
type MyType {
int x;
int y;
void setValue(int value) {
this.x = this.y = value;
}
void setValue(int x, int y) {
this.x = x;
this.y = y;
}
}
void main() {
MyType mt;
mt.setValue(2); // calls the first setValue
mt.setValue(1, 2); // calls the second setValue
}
When calling setValue
, all functions named setValue
on MyType
are considered candidates.
Functions with a different number of parameters than the given arguments are quickly discarded.
Then, if a function matching the argument types exactly is found, that becomes the nominated function.
Otherwise, for each remaining candidate, the input arguments are taken in turn to be coerced to the correct type. If an argument cannot be coerced, the candidate is dropped and the next one is considered. An error is thrown if no successful candidates are found.
Type coercion is very basic at the moment. int
and decimal
can be coerced to each other, and a type can be coerced to any of its parent types (if it has a parent).
It is possible to define operations on types that use the built-in operator symbols:
type MyType {
int x;
int y;
constructor () {}
constructor(int x, int y): x(x), y(y) {}
MyType operator +(int scalar) {
return MyType(this.x + scalar, this.y + scalar);
}
}
void main() {
MyType t1;
MyType t2 = t1 + 10;
}
If multiple overloads of the same operator are declared, overload resolution is the same as ordinary function calls.
CBL supports generic typing, similar to template
in C++.
type Vec2<T> {
T x;
T y;
void add(T scalar) {
this.x += scalar;
this.y += scalar;
}
}
void main() {
Vec2<int> intVec;
Vec2<decimal> decVec;
intVec.add(2);
decVec.add(2.0);
}
Vec2<T>
is a generic type, T
may refer to any type.
Vec2<int>
instantiates Vec2<T>
with type int
. The code in Vec2<T>
is quite literally copied, replacing T
with int
.
The copy is done once only, future references to Vec2<int>
will use the existing copied code. (Implementation detail: T
is not replaced, rather, it becomes an alias for int
during the copy operation).
It is possible to "extend" other types by setting a parent type, like so:
type Base {
int a;
void doSomething();
}
type Derived : Base {
int b;
void doOtherThing();
}
Note that it is not possible to override functions from parent types i.e. Derived
cannot override doSomething()
. This is because dynamic dispatch would be required (using a lookup table at runtime) which has not been implemented.
Singleton types are types where only one instance can exist. They can be compared to "namespaces" in other languages (but are not named as such to avoid confusion with Minecraft's namespace concept).
singleton SingleType {
int var = 10;
void foo() {
var = 20;
}
}
void main() {
SingleType.foo();
}
The body inside the singleton is like the global scope; variables and functions can be declared. However things declared within the scope are prefixed with the name of the singleton.
As seen in the example, function foo()
can reference var
without needing to qualify the name as SingleType.var
.
Besides the regular function (e.g. void main()
), there are a few more types of functions:
inline int add(int a, int b) {
return a + b;
}
void main() {
int sum = add(1, 2);
}
An inline function has the effect of copying its body into wherever a call to it is made. Arguments and return values are mapped to the call site.
In this example, the code is transformed into:
void main() {
int sum = 1 + 2;
}
Note that an inline function is not a macro (see below). The inline function is validated and compiled in its own function body. Only during the optimization phase does the body get substituted.
Macro functions are snapshots of code that are substituted directly into the code at their call site
macro void add(int a, int b) {
a + b;
}
void main() {
int sum = add(1, 2);
}
Unlike inline functions, which are inlined by the optimizer, a macro's body is subtituted in the parse tree, meaning it doesn't have to form a completely valid function by itself.
Note how return
is not used in add
. Doing so would insert a return
statement in main
, likely not what was intended.
Macros are used to allow special types such as _IRType
to work. _IRType
cannot be passed as function arguments, therefore macros are used to appear like function calls that hide implementation details.
Similar to macro types, except the body is executed at compile time to result in a constant value at runtime.
constexpr int mulNTimes(int a, int b, int n) {
int total = 0;
while(n--) {
total += a * b;
}
return total;
}
void main() {
int sum = mulNTimes(2, 3, 5);
}
In this nonsensical example, the mulNTimes
function multiplies a
and b
together n
times, summing the total (i.e. a * b * n
). The compiler runs this code, generating the value 30
therefore rewriting the function as:
void main() {
int sum = 30;
}