Flix For Java Programmers
The Java Virtual Machine isn’t limited to running programs written in the Java programming language. Many other compilers output Java bytecode. Some of these languages have so far remained exotic. Others have found a large following, for example Groovy, Clojure, Scala, and Kotlin. Now Flix, another language that brings some fresh approaches to the JVM, has entered the scene.
Apart from the JVM bytecode as compiler output, Flix doesn’t have much in common with Java. It’s a functional programming language, without the inheritance known from object-orientation. The idea of Flix is not to be a “better Java” that makes it easy for Java developers to switch. Rather, it’s about offering new tools that have not yet found their way into mainstream programming languages.
Explicit Side Effects
One such new element is the effect system. As a reminder, in programming, we distinguish between pure functions and those with side effects. A pure function returns only a value, and this value depends on nothing else than the arguments passed to the function (so-called referential transparency). A pure function is independent of any data anywhere outside of itself, and also doesn’t change the environment around it. This has very convenient practical implications. The evaluation of a pure function can be done by substitution: Each occurrence of a variable can be replaced by the expression it references. Also, we know that for the same arguments, a pure function will always yield the same result. Since it’s independent of any external context, this is true at any point in the program, at any time.
// Example: Flix Effect System
// 1. pure functions with no side effects
def isEven(x: Int32): Bool & Pure = x mod 2 == 0
def double(x: Int32): Int32 & Pure = x * 2
// 2. a function that only has a side effect. If you wonder about the
// type of the argument: it accepts everything that can be converted
// to a string (more formally: for which an instance of the type class
// "ToString" exists.
def write(s: t): Unit & Impure with ToString[t]= println(s)
def main(): Int32 & Impure =
let list = (1 :: 2 :: 3 :: 4 :: Nil);
// 3. List.count requires the argument to be a pure function
let noOfEvens = List.count(isEven, list);
write(noOfEvens);
// 4. Foreach is about executing a side effect on every element.
// Thus, it requires an impure function as argument.
List.foreach(write, list);
// 5. List.map demonstrates effect polymorphism. If the passed
// function is pure, so is map,if the passed function is
//impure, map is impure.
let doubled = List.map(double, list);
List.map(write, doubled);
0 // exit code
Listing 1
Pure and non-pure functions exist not only in Flix, but in every programming language. But only in very few programming languages can you distinguish them without analyzing the function’s implementation. Of course, in Java there’s the “void” return type, which says that the method has only side effects and no return value. But the reverse doesn’t exist: there’s no way to express in the signature that a method has no side effects / is a pure function.
Why Learn Flix?
If someone has a good command of Java but wants to expand their horizon and learn another programming language - which one should they choose? The Hamburg computer science professor Friedrich Esser has given a short and crisp answer to this: "It shouldn't be a step backward, the new language simply has to offer more than the old one." In this spirit, the article doesn't attempt to give a complete overview of Flix (such an overview would certainly fill an entire book), but to present a few selected features that make it stand out from Java.
In Flix, there is: Here the type system is extended with the effect types Pure
and Impure
. Listing 1 above shows a simple example. Arithmetic operations like modulo or multiplication are pure, so functions that use them are also pure. Output to the console is a side effect and, accordingly, not pure. It gets more interesting with functions that have other functions as parameters (higher-order functions). Here the implementation can restrict which functions are allowed as arguments. For example, counting the elements of a list that satisfy a certain predicate requires the predicate to be pure. The counterexample is foreach
: it doesn’t return anything, but is used to perform a side effect for each element of a list. Passing a pure function here would be meaningless - the return value would be discarded, so nothing would happen at all. It’s implicitly clear that only a function with a side effect makes sense. An effect system like Flix’s allows to make the implicit explicit: The signature of foreach enforces that the function applied to each element is not pure.
But there’s also the possibility to allow both. In this case, Flix talks about functions with polymorphic effects - they work with different effect types. List.map
is an example of this. It inherits the effect from the passed-in function, so map
itself can be pure or non-pure, depending on the function it applies to each element. However, the effect of the passed function is known at compile-time, so even with polymorphic effects, the compiler can check whether the effect is allowed or not.
Logical Programming With Datalog
Another feature of Flix is the integration of the logical description and query language Datalog. This allows predicate logical expressions to be defined and evaluated directly in a Flix program. Listing 2 shows how a simple knowledge base of facts and rules can be built. With a simple statement Father("Zeus", "Kronos")
(1.) we tell the system that Kronos is the father of Zeus. The rules to be applied to this (2.) can be recursive, with no restriction on the depth of recursion. Thus, our ancestor rule AncestorOf
states: Ancestor of X is any Y who is a parent of X. But so is any Z if it holds that Y is an ancestor of X, and Z is an ancestor of Y. This way we express the transitivity of the ancestor relationship.
// 1. The relationships we're interested in.
rel Father(child: String, father: String)
rel Mother(child: String, mother: String)
rel ParentOf(child: String, parent: String)
rel Child(parent: String, child: String)
rel AncestorOf(person: String, ancestor: String)
// 2. The direct relationships as facts
def getFacts(): #{ Father, Mother, ParentOf, Child, AncestorOf } = #{
Father("Zeus", "Kronos").
Mother("Zeus", "Rhea").
Father("Ares", "Zeus").
Mother("Ares", "Hera").
Father("Apollo", "Zeus").
Mother("Apollo", "Leto").
}
// 3. relationships that can be deduced
def getRules(): #{ Father, Mother, ParentOf, Child, AncestorOf } = #{
ParentOf(x, y) :- Father(x, y).
ParentOf(x, y) :- Mother(x, y).
Child(x, y) :- ParentOf(y, x).
AncestorOf(x, y) :- ParentOf(x, y).
AncestorOf(x, z) :- AncestorOf(x, y), AncestorOf(y, z).
}
// 4. Now we can query the knowledge base, e.g.
// who are the children of Zeus?
def main(): Int32 & Impure =
let a = query getFacts(), getRules() select (x) from Child("Zeus", x);
let b = query getFacts(), getRules() select (y) from AncestorOf("Apollo", y);
println("Children of Zeus = ${a}");
println("Ancestors of Apollo = ${b}");
0 // exit code
Listing 2
The syntax and semantics of Datalog borrow from Prolog. It’s a subset of Prolog, but not a full-fledged (Turing-complete) programming language itself. Embedded in another programming language this is of course no problem - Datalog can be used within a Flix program to describe facts and logical rules declaratively, while for everything else Flix itself is available.
Concurrency With Channels And Processes
While Java developers wait for Project Loom to get lightweight concurrency as a language feature, Flix relies on the approach also taken by Clojure and Go: Channels and coroutines. In Flix, the latter are named processes. This approach to concurrency is also called “CSP style” because it takes elements from the concept of Communicating Sequential Processes by Tony Hoare.
The spawn
keyword is used to execute a method in the background without starting a new native thread. Sometimes referred to as “green threads”, these routines are managed internally by Flix and executed on native threads as needed. It’s forbidden for different processes to communicate by accessing shared variables - which is good because in any programming language, code with shared mutable state is difficult to reason about and debug. Instead, Flix provides channels for communication between processes. A channel is a kind of lightweight queue, located in local memory, into which (one or more) senders put messages and (one or more) receivers read them. They come in two flavors: Buffering channels have a buffer size that determines how many items the channel can hold. Non-buffering channels have no capacity themselves. In this case, the channel is blocked until a receiver can hold a value, and vice versa. A small example can be found in Listing 3.
// a function that simulates latency
def doSomething(c: Channel[Int32], x: Int32, gen: Random.Random): Unit & Impure = {
let delay = abs(Random.nextInt64(gen)) mod 2000i64;
<- Timer.milliseconds(delay);
c <- x;
()
}
// Listens to a channel and outputs messages. If nothing
// arrives for 5 seconds, it terminates.
def printResults(c: Channel[Int32]): Unit & Impure = {
select {
case x <- c => println(x); printResults(c)
case _ <- Timer.seconds(5i64) => println("done")
};
()
}
def abs(i: Int64): Int64 & Pure = if (i < 0i64) Neg.neg(i) else i
/// Spawn a process for send and wait, and print the result.
def main(_args: Array[String]): Int32 & Impure = {
let gen = Random.new();
let results = chan Int32 100;
let l = List.range(0,100);
spawn printResults(results);
List.foreach(x -> spawn doSomething(results, x, gen), l);
<- Timer.seconds(10i64);
0
}
Listing 3
This type of communication is intended to avoid the race conditions that can occur with shared memory access. Flix follows the Go mantra: “Do not communicate by sharing memory; instead, share memory by communicating”.
Recursion Without Regret
Anyone who has tried to program functionally in Java will certainly have implemented a recursive algorithm sooner or later. Or rather, they will have failed in doing so, because Java severely restricts the use of recursion. The Java compiler doesn’t detect tail calls and consequently doesn’t perform any optimization on them. An example is shown in listing 4. If we try to recursively add all numbers up to 25000, but we’ll never get the expected result (312512500), instead we’ll get a StackOverflowError
.
class Tailcalls {
static int sumup(int n) {
return tailsum(0, n);
}
static int tailsum(int m, int n) {
if (n == 0) {
return m;
} else {
return tailsum(m +n, n - 1);
}
}
// Gibt einen StackOverflowError
public static void main(String[] args) {
System.out.println(sumup(25000));
}
}
Listing 4
Other JVM languages that have a stronger focus on functional programming, such as Scala, Kotlin, and Clojure, transform this code. The resulting bytecode is then as memory efficient as an iterative implementation.
def sumup(n: Int): Int = tailsum(0, n)
def tailsum(m: Int, n:Int): Int = {
match n {
case 0 => m
case _ => tailsum(m + n, n - 1)
}
}
// Gibt keinen Stack Overflow!!
def main(_args: Array[String]): Int32 & Impure =
println(sumup(25000));
0
Listing 5
Flix even goes a step further. It not only detects and eliminates tail-end recursion - the snippet in listing 5 calculates the sum for large values without any problems. The Flix compiler even detects “mutual” recursion, where two functions call each other at the end position. Listing 6 shows a textbook example of this in Java. It recursively determines whether a number is even or odd - but not for large numbers, due to lack of optimization. The equivalent code in Flix in listing 7, on the other hand, digests large values without complaint, since here the stack consumption is constant and does not grow with the value passed.
class MutualRecursion {
static boolean isOdd(int n) {
return (n == 0) ? false : isEven(n - 1);
}
static boolean isEven(int n) {
return (n == 0) ? true : isOdd(n - 1);
}
// Gibt einen StackOverflowError
public static void main(String[] args) {
System.out.println(isEven(25000));
}
}
Listing 6
def isOdd(n: Int32): Bool =
if (n == 0) false else isEven(n - 1)
def isEven(n: Int32): Bool =
if (n == 0) true else isOdd(n - 1)
// Gibt keinen Stack Overflow!!
def main(_args: Array[String]): Int32 & Impure =
println(isEven(25000));
0
Listing 7
All The Rest
If you’ve taken a look at the listings, you’ll have noticed that the four “big” features described above are by far not all that distinguish Flix from Java. A complete list of all differences would go beyond the scope of this article, but here are some examples:
Flix doesn’t know a value null
. The idea that a reference can also be null
has since been called a mistake by its inventor, and many programmers have felt despair facing a NullPointerException
. Other JVM languages, such as Kotlin and Scala, have introduced mechanisms to get by without null
. Flix adopts the Option
type, which has now also moved into Java, to indicate the possible absence of a value.
The Flix compiler is strict. If something is declared, be it a function or a local variable, it must be used. An identifier used in the environment can’t also be used locally, so there’s no “shadowing”. Even in Java all this isn’t considered good style and is often marked as a warning in the IDE. But Flix doesn’t have any warnings! It’s binary in this regard: Either it’s an error, and then compilation fails. Or it’s not, in which case there’s no warning.
Flix is typed, but the types don’t usually have to be specified. Instead, the compiler determines them using type inference. Although this would also be possible for function declarations, the Flix developers have decided to require them there: Both argument and return type must be specified, as to enforce minimal documentation.
As a functional programming language, Flix knows no classes or inheritance. Namespaces are used to structure the code base. Abstraction over different types is made possible by type classes, as they are used e.g. also in Haskell. A type class describes behavior on an abstract level that can be implemented by different types. You see the use of the type class ToString
in the sample code. If there’s a definition of how to convert a type into a string representation, it’s said that there is a type class instance of ToString
for this type. Functions can then refer to this type class. That is, for example, a function can be defined to accept all argument types for which there is a ToString
instance (like our little write
function).
Support for unit tests is built directly into the compiler in Flix. The language has the annotation “@test”. If the compiler is called with the argument “test”, all correspondingly annotated functions are executed and the results are output.
Java Interoperability
The great advantage of a language that runs on the JVM is that it can draw on existing Java libraries. So from day one, a huge ecosystem of libraries, including clients for any databases and middleware, is available. And all the work that has already gone into custom Java libraries has not been in vain, as they can continue to be used.
def main(_args: Array[String]): Int32 & Impure =
import new java.io.File(String) as newFile;
import java.io.File.exists();
let f = newFile("HelloWorld.txt");
println(exists(f));
0
Listing 8
To use Java classes, Flix provides the import
keyword (it is not needed internally in Flix, the keyword for importing identifiers from another namespace is use
). However, the import is quite fine-grained. It is not enough to import a class, each method must be imported separately. With as
the name can be replaced by an alias for use in Flix. Constructors can be made visible with import new
, fields with import get
. Listing 8 shows a minimal usage of java.io
.
An exciting language in its infancy
Flix is a very young language and still far from production maturity. It’s currently not a serious alternative to Java or the other established JVM languages mentioned at the start. What makes it special, however, is the ambitious vision. It goes far beyond cosmetic improvements or porting an existing language to the JVM. With the effect system and the Datalog integration, Flix offers real innovation. In other parts, it’s done plenty of smart cherry-picking from other languages (e.g. type classes). It’ll be exciting to see how Flix will evolve in the next few years, and also which Flix features will be adopted by established languages sooner or later.
The source code examples for this article are available on Codeberg.