Skip to main content

Custom Functions

Although Plasma comes with a huge set of functions, you might find that you need a function that is very specific to your use case. For this, you can write your own custom functions.

Please note that writing custom functions is an advanced feature and you should only create your own functions after you are well versed with Plasma.

Details

The syntax for writing a custom function is as follows:

def func functionName(def Type1 arg1, def Type2 arg2, def Type3 arg3) -> returnType {
// Do something with arg1, arg2 and arg3
// Optionally return a value
}

Let's break this down:

  • def - This is an optional keyword. It is used to explicitly declare that the following is a new definition. This is same as the def keyword when declaring a class, enum or a new variable.
  • func - This keyword is used to explicitly declare that the following is a function. This is similar to the class | objectBlueprint keyword when defining a new class or enum | categoryBlueprint keyword when defining a new enum.
  • functionName - This is the name of the function. It can only have alphanumeric characters and underscores. It should start with a letter.
  • def Type1 arg1, def Type2 arg2, def Type3 arg3 - These are the paramters you want to pass to the function.
    • def - This is an optional keyword. It is used to explicitly declare that the following is a new definition. This is same as the def keyword when declaring a class, enum or a new variable.
    • TypeN - This is the type of the parameter. This is a predefined type like int, real, string, boolean etc. or a custom type (like a class or an enum) that you have defined. You can also pass a type parameter (Also called a generic) to the function (Generics).
    • argN - This is the name of the parameter (also called an argument).
    • Some functions might take zero parameters. In such a case, you would omit this part.
    • Parameters can also be ref or varargs.
  • -> returnType - This is the return type of the function.
    • It could be a predefined type like int, real, string, boolean etc. or a custom type (like a class or an enum) that you have defined.
    • If your function does not return anything, you can omit this part.
    • You can also pass a type parameter (Also called a generic) to the function (Generics).
  • { ... } - This is the body of the function. Here you write the code that you want to execute when the function is called.
    • If your functions returns something, you need to explicitly return the value.

Let's look at a simple example.

def func add(int a, int b) -> int {
return a + b;
}
int x = 12;
int y = 13;

int sum1 = add(x, y); // First way to call the function
int sum2 = x.add(y); // Second way to call the function

log(sum1); // Expected output: 25
log(sum2); // Expected output: 25

In this example, we are defining a function called add that takes two parameters a and b of type int and returns an int. The body of the function is return a + b;. This means that when the function is called, it will add the two parameters and return the result.

Now let's see an example of a function that does not return anything.

def func printHello(string x) {
log(x);
}

In this example, we are defining a function called printHello that takes a single parameter x of type string and does not return anything. The body of the function is log(x);. This means that when the function is called, it will print the value of x to the console.

Now let's see an example of a function that takes multiple parameters of different types.

def func repeatStringMultipleTimes(string a, int times) -> string {
return [1...times].map((i) -> {
return a;
}).joinToString("");
}

In this example, we are defining a function called repeatStringMultipleTimes that takes two parameters a and times of type string and int respectively and returns a string. The body of the function is return a * times;. This means that when the function is called, it will repeat the string a for times number of times and return the result.

note

Keen-eyed readers might have noticed that Plasma already has a function that has this functionality called repeatString.

Let's add one more parameter to the function to make it more flexible.

def func repeatStringWithSeparator(string a, int times, string separator) -> string {
return [1...times].map((i) -> {
return a;
}).joinToString(separator);
}

At this point, you might be thinking, that every time you want to keep the separator optional, you have to pass in a default value of an empty string. This is not ergonomic. To solve this problem, plasma has a feature called Function Overloading.

Function Overloading

Function overloading allows you to define multiple functions with the same name but different parameters. The return type can be different too. When you call a function, the compiler will select the most appropriate function based on the parameters you pass.

Let's see how we can use function overloading to solve the problem we just discussed.

def func repeatStringWithSeparator(string a, int times, string separator) -> string {
return [1...times].map((i) -> {
return a;
}).joinToString(separator);
}

def func repeatStringWithSeparator(string a, int times) -> string {
return repeatStringWithSeparator(a, times, "");
}

Notice how we have two functions with the same name but different parameters. In fact this style of function overloading is very common when you want to have optional parameters or default parameters.

Let's look at another example.

def func checkIfAllNull(List<string> list) -> boolean {
return list.allMatch((x) -> {
return x == null;
});
}

def func checkIfAllNull(Map<string, string> map) -> boolean {
return map.getEntries().allMatch((x) -> {
return x.getValue() == null;
});
}

Here we see that we can make use of function overloading when we want to have similar functions on different types.

Function overloading is a powerful feature that allows you to write more flexible and reusable code. But it should be used cautiously otherwise you might create multiple functions that could match. If more than one function can match and there is no clear way to resolve ambiguity, Plasma will throw a compile time error!

See Function Name Resolution to understand which function gets called in case of function overloading.

def func prependAndAppendElement(List<string> list, string item) {
list.insertElementAt(item, 1);
list.addElement(item);
}

def List<string> list = ["a", "b", "c"];

prependAndAppendElement(list, "x");

log(list); // Expected output: ["x", "a", "b", "c", "x"]. But it is ["a", "b", "c"]!

In the above example, we have defined a function called prependAndAppendElement that takes a list and an item of type string and prepends and appends the item to the list. But when you log the list, you see that the list has remain unchanged. This is because Plasma functions are value types. This means that whenever you pass in a function argument, it is passed by value. This creates a copy of the argument and the original argument remains unchanged. So how do you modify the original argument? This is where ref comes into play.

ref keyword

All parameters passed into a function are passed by value. This means that the function gets a copy of the argument and any modifications to the argument do not reflect in the original argument. If you want to modify the original argument, you can use the ref keyword.

def func prependAndAppendElement(List<string> ref list, string item) { // Notice the `ref` keyword
list.insertElementAt(item, 1);
list.addElement(item);
}

def List<string> list = ["a", "b", "c"];

prependAndAppendElement(list, "x");

log(list); // Expected output: ["x", "a", "b", "c", "x"]. Ref works as expected!

In the above example, we have defined a function called prependAndAppendElement that takes a list with ref keyword and an item of type string. Whenever you use ref keyword, you are telling Plasma that the argument is passed by reference. This means that the function gets a reference to the original argument and any modifications to the argument DO reflect in the original argument.

Let's look at another example.

def func increment(int ref x) {
x = x + 1;
}

def int x = 10;

increment(x);

log(x); // Expected output: 11. Ref works as expected!

In the above example, we have defined a function called increment that takes an integer with ref keyword. Whenever you use ref keyword, you are telling the Plasma runtime that the argument is passed by reference. This means that the function gets a reference to the original argument and any modifications to the argument DO reflect in the original argument.

Now let's extend our implementation of prependAndAppendElement for other types of lists (using function overloading).

def func prependAndAppendElement(List<string> ref list, string item) {
list.insertElementAt(item, 1);
list.addElement(item);
}

def func prependAndAppendElement(List<int> ref list, int item) {
list.insertElementAt(item, 1);
list.addElement(item);
}

def func prependAndAppendElement(List<real> ref list, real item) {
list.insertElementAt(item, 1);
list.addElement(item);
}

You might notice, that although function overloading is being used, the code for each function is exactly the same. And if you really wanted to make your function prependAndAppendElement exhaustive, you would have to write a function for each type (which is not very practical).

This is where Generics come into play.

Generics

Generics are an advanced feature in programming, where the type itself becomes a variable!

Let's see how we can leverage generics to make our code more reusable.

/* OLD WAY */
def func prependAndAppendElement(List<string> ref list, string item) {
list.insertElementAt(item, 1);
list.addElement(item);
}

def func prependAndAppendElement(List<int> ref list, int item) {
list.insertElementAt(item, 1);
list.addElement(item);
}

def func prependAndAppendElement(List<real> ref list, real item) {
list.insertElementAt(item, 1);
list.addElement(item);
}

Notice the three overloaded functions carefully and see what's different about them. If you look closely, you will see that the only thing that's different is that the first function uses string and the other two use int and real respectively. What if we could make the type itself a variable?

def func prependAndAppendElement<T>(List<T> ref list, T item) {
list.insertElementAt(item, 1);
list.addElement(item);
}

In the above example, we have rewritten our logic to take a generic type T instead of a specific type <string> | <int> | <real>. Now our function works for any type of list.

How to use generics?

  • Firstly we need to specify all the type parameters that the function expects in the function signature itself. This is done using angle brackets <T, U...> just after the function name.
  • Wherever we use the type T, we are telling Plasma that we want to use a generic type parameter instead of a specific type.

Let's take another example where you want to invert a map.

def func invertMap<K, V>(Map<K, V> map) -> Map<V, K> {
Map<V, K> invertedMap = new Map<V, K>();
map.getEntries().forEach((x) -> {
invertedMap.put(x.getValue(), x.getKey());
});
return invertedMap;
}

In the above example, we have defined a function called invertMap that takes a map with generic type parameters <K, V> and returns a map with generic type parameters <V, K>.

Anytime you get confused about the type parameters, just substitute them with specific types and see if the logic makes sense. For example, if you see T, just think of it as string or int or real.

Now let's try to create a function that takes in two values and returns the smaller of the two.

def func min<T>(T a, T b) -> T {
return a < b ? a : b; // Plasma throws error here because it doesn't know how to compare T
}

In the above example, we have defined a function called min that takes two values of type T and returns the smaller of the two. But Plasma throws an error because it doesn't know how to compare two values of type T. Let's try to substitute T with int and see if the logic makes sense.

def func min(int a, int b) -> int {
return a < b ? a : b;
}

This makes sense. Now let's substitute T with List<int> and see if the logic makes sense.

def func min(List<int> a, List<int> b) -> List<int> {
return a < b ? a : b;
}

This doesn't make sense because a list cannot be compared to another list. So how do you define a function that takes in generic types but not all types? This is where Generic Constraints come into play.

Generic Constraints

Generic constraints are a way to tell Plasma that the type parameter must satisfy certain conditions.

def func min<T extends Comparable>(T a, T b) -> T { // Notice the `extends Comparable` part
return a < b ? a : b;
}

In the above example, we have defined a function called min that takes two values of type T and returns the smaller of the two. We have added a Generic Constraint using the extends Comparable part. This means that the type parameter T must be Comparable.

What is this new word Comparable that we are using? This is a special type that is defined in Plasma that decides whether two values of the same given type can be compared to each other. There are 6 types that are Comparable in Plasma.

  • int
  • real
  • string
  • Date
  • DateTime
  • Time

What are the other types of constraints that we can use?

As of now, Plasma supports the following constraints (More constraints are coming soon!).

  • T extends Comparable - This is the constraint that we saw in the previous example. It means that the type parameter must be Comparable.
  • T extends Number - This means that the type parameter must be a subtype of Number (which is basically int and real).
Pro-tip

Both Comparable and Number are predefined interfaces in Plasma. As of now, Plasma does not support user defined interfaces (coming soon!).

Varargs

Varargs are a way to pass a variable number of arguments to a function. This is useful when you want to pass in a list of arguments to a function.

To illustrate the use of varargs, let's try to implement a functions called logWithNewLines which is similar to log function in plasma but it logs each value in a new line.

def func logWithNewLines(string value1) {
log(value1);
}

def func logWithNewLines(string value1, string value2) {
log(value1);
log(value2);
}

def func logWithNewLines(string value1, string value2, string value3) {
log(value1);
log(value2);
log(value3);
}

You might try to implement logWithNewLines via function overloading but as you can see, that would be a very tedious process. This is where varargs come into play.

def func logWithNewLines(vararg string values) { // Notice the `vararg` keyword
values.forEach(x -> {
log(x);
});
}

logWithNewLines("a", "b", "c"); /* Expected output:
a
b
c
*/

In the above example, we have defined a function called logWithNewLines that takes a vararg of type string. When you make a parameter vararg, it acts as List<T> where T is the type of the vararg.

Let's create another example where we want to find the product of a list of numbers.

def func product(vararg int values) -> int {
return values.reduce((accumulator, currentValue) -> {
return accumulator * currentValue;
});
}

int x = product(1, 2, 3, 4, 5); // x = 120

In the above example, we have defined a function called product that takes a vararg of type int.

Considerations
  1. Varargs can only be used as the last parameter in a function. Max one vararg parameter is allowed in a function.
  2. Varargs and ref cannot be used together.

Function Resolution for overloaded functions

When you have a function that is overloaded with generics, generics constraints and /or varargs, which function gets called?

def func overloadedFunction<T>(T value) -> T {
return value;
}

def func overloadedFunction<T>(vararg T values) -> List<T> {
return values;
}

def func overloadedFunction<T extends Comparable>(T value) -> T {
return value;
}

def func overloadedFunction<T extends Comparable>(vararg T values) -> List<T> {
return values;
}


overloadedFunction(1, 2, 3, 4, 5); // Which function will be called? -> 4th one
overloadedFunction(1); // Which function will be called? -> 3rd one
overloadedFunction("a", "b", "c"); // Which function will be called? -> 2nd one
overloadedFunction("a"); // Which function will be called? -> 1st one

The simple answer is that the function with the most specific parameters gets called.

The priority order is (highest priority to lowest priority) :

  1. Function with non-generic and non-varargs parameters
  2. Function with non-generic and varargs parameters
  3. Function with generic with Number constraint and non-varargs parameters
  4. Function with generic with Number constraint and varargs parameters
  5. Function with generic with Comparable constraint and non-varargs parameters
  6. Function with generic with Comparable constraint and varargs parameters
  7. Function with generic and non-varargs parameters
  8. Function with generic and varargs parameters

Variable Shadowing

Usually, whenever you try to declare a variable with a name that is already defined, you get a compile time error.

def int x = 10;
def int x = 20; // This will throw a compile time error
log(x); // What does this x refer to?

This is because when you use the variable x in the last line, it is not clear which x you are referring to.

But you can scope the variable x to a specific block using curly braces {}.

{
def int x = 10; // This is allowed. This x is only accessible in this block and all the inner scopes
log(x); // This will print 10
{
log(x); // This will also print 10
}
}
{
def int x = 20; // This works
log(x); // This will print 20
}
log(x); // This will throw a compile time error as no `x` is visible here

Let's take another example, where we encounter an issue

def int x = 10;
{
def int x = 20; // This is not allowed as an inner scope cannot have a variable with the same name as an outer scope
log(x); // This will print 20
}
log(x); // This will print 10

This is the classical behaviour of Plasma.

But functions are a special case. Take a look at the following example:

def int x = 10;

def func logX(def int x) { // This is allowed. This x is only accessible in this function
log(x); // This will print whatever is passed in the argument
}

logX(20); // This will print 20
log(x); // This will print 10

Let's take another example, where we declare a variable inside the function body:

def int x = 10;

def func yourCustomFunction() { // This is allowed. This x is only accessible in this function
def int x = 20; // This is allowed. This x is only accessible in this function
log(x); // This will print 20
}

As you can see, the variable x inside the function body hides the outer scope variable x. This is called Variable Shadowing.

But what if you want to access the outer scope variable x inside the function body? You can do that by using the this keyword.

def int x = 10;

def func yourCustomFunction() { // This is allowed. This x is only accessible in this function
def int x = 20; // This is allowed. This x is only accessible in this function
log(x); // This will print 20
log(this.x); // This will print 10
}

You are not limited to just reading the outer scope variable using the this keyword. You can also modify it.

def int x = 10;

def func yourCustomFunction() { // This is allowed. This x is only accessible in this function
def int x = 20; // This is allowed. This x is only accessible in this function
log(x); // This will print 20
this.x = 30; // This will modify the outer scope variable x to 30
}

yourCustomFunction();
log(x); // This will print 30
Consideration

You cannot modify the this keyword itself.

For example, the following code will throw a compile time error.

def int x = 10;

def func yourCustomFunction() {
this = 20; // This will throw a compile time error
}