Introduction
- This document introduces the main features of zenlang using a series of small examples.
- All code blocks below with 'namespace' in the first line are fully compilable/executable code. For example, any code block starting with:
- As far as possible, this introduction uses plain language and code samples to demonstrate well-known concepts.
- Before proceeding, please make sure the software is set up as per instructions in the The Installation Guide guide.
Getting Started
This document introduces the salient features of the language with a series of code samples, starting with a simple hello world program and proceeding gradually towards increasing levels of complexity.
Hello World application
- Open a console window and ensure that zcc is on the path. Type: If you see the help output of zcc the compiler is set up properly.
- Create a directory to try out the initial code and cd into it.
- Use any text editor to create your first zen source file, called say, HelloWorld.zpp. Enter the following code:
namespace HelloWorld;
int main(const list<string>& argl) {
unused(argl);
dlog "Hello world\n";
return 0;
}
In this code example, we:
- we specify a namespace in the first line
- define a main function
- receive the command line arguments as the argl variable which is a list of strings
- Specify it as unused to suppress compiler warnings
- Write the "Hello World" string
- Return 0 to indicate success
- Compile it as follows:
- If all goes well, you should see the compiler output messages with the last two lines reading:
TESTS(0) PASSED(0/0)
Done in 1863 ms.
In case of any errors, you will see the appropriate error messages.
- The current directory should now have an executable file called HelloWorld.exe (in Windows), or HelloWorld (on Linux)
- When you run it, you will, predictably, see the following output:
- At this stage, if you list the contents of the current directory, you will see a subdirectory called GetStarted.bld.
- This is where all the generated files are present.
- Here, in addition to the generated .cpp, .hpp files, you will also see the project file. On Windows, it will be GetStarted.vcproj (MSVC project), while on Linux it will be GetStarted.pro (QtCreator project).
- You can now open this file in MSVC or QtCreator for further development.
- When the GetStarted.zpp file is changed, the corresponding GetStarted.cpp and GetStarted.hpp files get regenerated.
Writing a function
- Modify the source file as follows:
namespace FirstFunction;
(int op) MyFunction (int ip) {
return (ip * 10);
}
int main(const list<string>& argl) {
unused(argl);
local frv = MyFunction(7);
dlog "The function return value is: %{rv}\n" @ {@rv:frv.op};
return 0;
}
In this code, we see the following changes:
- MyFunction is a function definition.
- The striking feature here is that the function return value looks just like the function input parameter list.
(int op) MyFunction (int ip) {
return (ip * 10);
}
- In zenlang, functions can return multiple return values, just as it can accept multiple input parameters.
- This feature is reflected in the syntax, where both input and output is a list of named parameters.
- In this example, MyFunction accepts ip (an int), as an input parameter and returns op (also an int) as the return value.
- In the generated C++ code, the return value of a function is implemented as a struct containing the values.
- We can define functions to return multiple values. For example:
(int op, string msg) MyMultipleFunction (int ip) {
return (ip * 10, "Hello world");
}
- The implementation of function MyFunction is simple, it merely returns the input value multiplied by 10.
- In the main() function, we see a new line
local frv = MyFunction(7);
- This line declares a local variable frv and initializes it with the return value of the function call.
- We do not need to specify the type of frv, it is inferred at compile-time by the compiler.
- The next line is as follows:
dlog "The function return value is: %{rv}\n" @ {@rv:frv.op};
This line introduces the string format operator, the @ symbol.
- It has a string expression on the LHS (the format string) and a map on the RHS (the string parameters).
- The format string contains one or more named parameters in the format %{rv}, which in this case specify rv as the name of the parameter.
- The value of the parameter is specified in the string parameter list.
- The key @rv in the string parameter list is syntactic sugar for the string "rv".
- The value frv.op in the string parameter list refers to the op return parameter of the MyFunction function call.
- After compiling and running this code, you will see the output:
The function return value is: 70
Since MyFunction was called with 7 as the argument, it returns 70.
Integrated test driven development
- If you run the executable with ---u as a command line parameter (note three hyphens), you will see the following output: The ---u parameter causes the application to execute any test cases found within the executable, in this case, none.
- It is recommended that the main() function be only used as the entry point into the application for the primary functionality of the application itself, and that test cases be used for unit testing individual functions.
- We will now see how to use test cases instead of the main function.
- Modify the source file as follows:
namespace FirstFunctionUnitTest;
(int op) MyFunction(int ip) {
return (ip * 10);
}
int main(const list<string>& argl) {
unused(argl);
local frv = MyFunction(7);
dlog "The function return value is: %{op}\n" @ {@op:frv.op};
return 0;
}
test (int passed)testMyFunction() {
local frv = MyFunction(7);
return (frv.op == 70);
}
Here the first two functions remain the same. We have added a third function, testMyFunction. This function is prefixed with the keyword 'test' to indicate to the compiler that it is a unit test.
- A test function must necessarily have an output parameter called 'passed'. It should return a non-zero value in this parameter if this test case has passed. In this case, we call MyFunction with argument 7 and test that it has returned 70.
- After compiling this code, you should see the last three lines of the compiler output as follows:
PASS : FirstFunctionUnitTest::testMyFunction
TESTS(1) PASSED(1/1)
Done in 2219 ms.
- Note that the test cases are executed as a part of the build process.
- The first line tells us that the test case called 'FirstFunctionUnitTest::testMyFunction' has passed.
- The next line gives a summary of running the tests.
- TESTS(1) tells us the total number of test cases executed.
- PASSED(1/1) is the statistics on the number of tests passed. This number is shown as x/y because test cases are executed in fibers. More on that later.
- If the test case had failed, we would see the following:
*FAIL*: FirstFunctionUnitTest::testMyFunction
TESTS(1) PASSED(0/1) *** FAILED(1) ***
Done in 2269 ms.
Here we see some additional information - the number of test cases that failed.
Modular test driven development
- An application typically consists of many source files, only one of which will have a main() function.
- However, it is prefered to test smaller parts of the application in isolation.
- In zenlang, it is possible to test each source file in isolation by writing a set of functions and their unit tests within one source file which does not have a main() function.
- We will now modify the earlier source file, with the only change being to remove the main function, as follows:
namespace FunctionUnitTest;
(int op) MyFunction(int ip) {
return (ip * 10);
}
test (int passed)testMyFunction() {
local frv = MyFunction(7);
return (frv.op == 70);
}
We have now removed the main() function. If we compile this file as before, we will get a linker error because the missing main function is missing.
- We need to compile it as follows:
zcc --genMain GetStarted.zpp
The --genMain parameter will tell the compiler to generate a default main function that does nothing. However, the test cases are executed and the results are displayed as before.
- This feature allows us to work on individual modules of a project and develop unit tests on a per-function basis without affecting the whole. This is made all the more possible because zenlang programs have no global state other than I/O.
Nested functions
- In zenlang, we can define functions within functions. For example:
namespace InnerFunctionDemo;
test (int passed)testMyFunction() {
function (int op) MyFunction(int ip) {
return (ip * 10);
};
local frv = MyFunction(7);
return (frv.op == 70);
}
In this example, we see:
- MyFunction is a nested function within testMyFunction.
- Inner function definitions need to be prefixed with the 'function' keyword.
- Inner function definitions also need to have a terminating semicolon.
Function signatures and function implementations
- Using zenlang, we can define function signatures and provide differing implementations for the same at other places in the code. For example:
namespace FunctionSignatureTest;
(int op) MyFunctionSig(int ip);
test (int passed)testMyFunctionSig() {
local fimpl = MyFunctionSig {
return (ip * 11);
};
local frv = fimpl(7);
return (frv.op == 77);
}
- At the beginning we define a function signature called MyFunctionSig using this line:
(int op) MyFunctionSig(int ip);
- Inside function testMyFunctionSig, we see the following line:
local fimpl = MyFunctionSig {
return (ip * 11);
};
Here, we are creating an implementation for the MyFunctionSig function signature.
- Unlike inner functions, we do not need to specify the in parameters, the out parameters or the 'function' keyword, however:
- The parameters are available to the implementation code block.
- In this example, the implementation code block refers to the in-parameter 'ip'.
- In a sense, defining and implementing a function signature in zenlang is akin to defining and using a struct in C.
- Just as the programmer knows the names/type of the struct members, the programmer also knows the names/type of the input and output parameters of a function signature.
- The local variable fimpl is now a function object containing this implementation and can be called like a function.
Passing function objects
- Function objects can be passed as regular variables to other functions.
For example: namespace FunctionObjectTest;
(int op) MyFunctionSig(int ip);
(int op) MyCallerFunction(MyFunctionSig& fn) {
local frv = fn(4);
return (frv.op);
}
test (int passed)testMyFunctionObject() {
local fimpl = MyFunctionSig {
return (ip * 12);
};
local frv = MyCallerFunction(fimpl);
return (frv.op == 48);
}
- Here, we have added a new function MyCallerFunction which takes an object of type MyFunctionSig as a parameter.
- Inside function testMyFunctionObject, we make the following changes:
- Instead of calling fimpl directly as in the previous example, we pass fimpl to the function MyCallerFunction.
local frv = MyCallerFunction(fimpl);
- MyCallerFunction calls the object as a function and assigns its return value to a local variable, frv.
- MyCallerFunction returns frv.op as its own return value.
Function definition can refer to external variables
- A function implementation (aka definition) can refer to variables outside itself. For example:
namespace FunctionObjectTestXRef;
(int op) MyFunctionSig(int ip);
(int op) MyCallerFunction(MyFunctionSig& fn) {
local frv = fn(4);
return (frv.op);
}
test (int passed)testMyFunctionObject() {
local xvar = 6;
local fimpl = MyFunctionSig {
return (ip * xvar);
};
local frv = MyCallerFunction(fimpl);
return (frv.op == 24);
}
- This is the same as the previous example, with the following changes.
- testMyFunctionObject defines a local int variable called xvar.
- The implementation of MyFunctionSig refers to this external variable with the function body.
local fimpl = MyFunctionSig {
return (ip * xvar);
};
- When this function object is invoked by MyCallerFunction, the return value is the input parameter 'ip' multiplied by the external variable 'xvar'.
- In summary, just as arguments are used to pass values into the function at the time when it is invoked, this feature (aka closures) is used to pass values into the function at the time when it is defined.
Queued Function Calls
- In zenlang, you can place function calls in a queue, to be executed after the current function has finished. Here's a quick demo:
namespace QueuedFunctionDemo;
(int op) MyFunction (int ip) {
dlog "MyFunction called with: %{ip}\n" @ {@ip:ip};
return (ip * 10);
}
int main(const list<string>& argl) {
unused(argl);
run => MyFunction(7);
dlog "main() returned\n";
return 0;
}
- Here, we specify the following statement: This statement is used to place a call to MyFunction (with 7 as a parameter) in the runtime queue. The function is not actually called at this point, it is only enqueued for later execution.
- This enqueued function is known as a closure.
- When this code is compiled and executed (this code uses main() instead of a test function), you will see the following output:
main() returned
MyFunction called with: 7
- You will observe 2 points here:
- The function MyFunction is called after main() has returned
- The application does not end after main() has returned, but after all tasks in the runtime queue has been executed.
Queued Functors
- You can also enqueue a functor in the runtime queue, as follows:
namespace QueuedFunctorDemo;
(int op) MyFunction (int ip);
int main(const list<string>& argl) {
unused(argl);
local fimpl = MyFunction {
dlog "MyFunction called with: %{ip}\n" @ {@ip:ip};
return (ip * 11);
};
run => fimpl(7);
dlog "main() returned\n";
return 0;
}
- Here, we define a functor as follows:
local fimpl = MyFunction {
dlog "MyFunction called with: %{ip}\n" @ {@ip:ip};
return (ip * 11);
};
- Then we enqueue it as follows:
Queuing multiple functions
- When a function is queued, you would typically want to specify another function to be executed after the first one. This is called continuations and is easily done in zenlang, as demonstrated below:
namespace ContinuationsDemo;
(int op) MyNextFunction (int ip) {
dlog "MyNextFunction: ip:%{ip}\n" @ {@ip:ip};
return (ip * 5);
}
(int op) MyFunction (int ip) {
dlog "MyFunction: ip:%{ip}\n" @ {@ip:ip};
return (ip * 10);
}
int main(const list<string>& argl) {
unused(argl);
run => MyFunction(7) => MyNextFunction(3);
dlog "main() returned\n";
return 0;
}
- Here, we define one more function, MyNextFunction, which is identical to MyFunction for ease of understanding.
- Next, we modify the run statement to include a call to MyNextFunction after MyFunction:
run => MyFunction(7) => MyNextFunction(3);
This statement tells the runtime to first call MyFunction, then call MyNextFunction.
- The output of this code is as follows:
main() returned
MyFunction: ip:7
MyNextFunction: ip:3
Observe that the 3 functions (main, MyFunction and MyNextFunction) were called one after the other.
Passing data between function calls in a continuation
- This code demonstrates how to pass the return value of one function call to the next.
namespace ContinuationsDataDemo;
(int op) MyNextFunction (int ip) {
dlog "MyNextFunction: ip:%{ip}\n" @ {@ip:ip};
return (ip * 5);
}
(int op) MyFunction (int ip) {
dlog "MyFunction: ip:%{ip}\n" @ {@ip:ip};
return (ip * 10);
}
int main(const list<string>& argl) {
unused(argl);
run => MyFunction(7) => MyNextFunction(cMyFunction.op);
dlog "main() returned\n";
return 0;
}
- We have modified the run statement as follows:
run => MyFunction(7) => MyNextFunction(cMyFunction.op);
- The return value of every function call in the continuation is automatically assigned to a variable with the same name as the function, prefixed with a 'c'.
- So the return value of MyFunction is stored in an automatic variable called cMyFunction.
- The scope of this variable is the continuation statement.
- In the above statement, MyNextFunction is called with the output of MyFunction as an input parameter.
- The output of this code is as follows:
main() returned
MyFunction: ip:7
MyNextFunction: ip:70
As expected, the input parameter of MyNextFunction is 70, the value returned by MyFunction.
Naming function calls in a continuation
- We can also give our own names to function calls in a continuation, if:
- If we find the default names unwieldy, or
- If there is a naming conflict For example:
namespace NamedContinuationsDemo;
(int op) MyNextFunction (int ip) {
dlog "MyNextFunction: ip:%{ip}\n" @ {@ip:ip};
return (ip * 5);
}
(int op) MyFunction (int ip) {
dlog "MyFunction: ip:%{ip}\n" @ {@ip:ip};
return (ip * 10);
}
int main(const list<string>& argl) {
unused(argl);
run => x:MyFunction(7) => MyNextFunction(x.op);
dlog "main() returned\n";
return 0;
}
- We have modified the run statement as follows:
run => x:MyFunction(7) => MyNextFunction(x.op);
- The call to MyFunction is named 'x', and is refered to by that name.
Calling continuations from within a continuation
- When a continuation is queued up and executed, a function called from that continuation can in turn enqueue another continuation.
For example: namespace SubContinuationsDemo;
(int op) MySubFunction (int ip) {
dlog "MySubFunction: ip:%{ip}\n" @ {@ip:ip};
return (ip * 3);
}
(int op) MyNextFunction (int ip) {
dlog "MyNextFunction: ip:%{ip}\n" @ {@ip:ip};
return (ip * 5);
}
(int op) MyFunction (int ip) {
run => MySubFunction(11);
dlog "MyFunction: ip:%{ip}\n" @ {@ip:ip};
return (ip * 10);
}
int main(const list<string>& argl) {
unused(argl);
run => x:MyFunction(7) => MyNextFunction(x.op);
dlog "main() returned\n";
return 0;
}
- Here, we have made two changes to the earlier demo.
- We have added one more function called MySubFunction, which is identical to MyNextFunction.
- We have enqueued the new function from within MyFunction
run => MySubFunction(11);
- The output of this code is as follows:
main() returned
MyFunction: ip:7
MySubFunction: ip:11
MyNextFunction: ip:70
MyNextFunction: ip:0
- The first 4 lines of this output is as expected - it consist of:
- the 3 lines from the previous example
main() returned
MyFunction: ip:7
...
MyNextFunction: ip:70
- and one additional line
...
MySubFunction: ip:11
...
showing that MySubFunction has been invoked as well.
- However, the output also has one additional line, the last one.
- This introduces the inner workings of the runtime queue. Each item in the runtime queue is, not just a function call, nor just a continuation. Each item is a stack of continuations, known as a fiber.
- Whenever a continuation is enqueued from within a continuation, it creates a copy of the current fiber (which is a stack of continuations) and pushes the new continuation on top of the new fiber.
- In the output above, the following lines:
...
MySubFunction: ip:11
...
MyNextFunction: ip:0
were generated by the second fiber that was created when the run statement in MyFunction was executed.
- A detailed description of the runtime queue is given in the document The Runtime Queue.
Going forward
- Please see the test suite tests/test.zpp for examples of basic expressions
- Please see the test suite tests/testStatements.zpp for examples of basic flow control statements
- Please see the project at examples/gui/ for an example of a GUI application