Scripting with Java II – Reducing Formality

In the previous article on Scripting with Java, we looked at how Java became more approachable as a scripting language using the java launcher to execute single source file Java applications. This allows Java to have the typical ‘write-execute’ development loop that other scripts languages have.

Another trait common among script languages is they have very little formality. To address concerns around the "verbosity of Java" Project Amber was launched in the OpenJDK with the goal of improving developer productivity and experience when writing in Java. In this article we will look at some of the changes introduced to the Java language as a part of Project Amber and how it helps to make Java a more pratical choice to write scripts in.

Code Example

The example we will be using to explore some of the Java language changes is a simple script for scanning text files for a phrase. This script will need to accept user input for; a phrase to search for, the directory to search in, and the file type that will be searched. The script will also need to print formatted messages to the console and display the results of a search. Let’s take a look at how some of the new language changes in Java make these tasks easier to accomplish.

The full code example can be seen here: LogScanner.java

Writing and Formatting Multi-Line Messages with Text Blocks

Printing a multi-line and formatted messages to console is a common need when writing a script. Messages could be needed for providing information on how to use the script, results, or when an error occurs. Working with a multi-line formatted string has often been a painful experience in Java. Introduced in Java 15, JEP 378, Text Blocks aims to address this pain point.

A text block begins and ends with the triple quote delimiter: """. Within the """ formatting and line separations are preserved without requiring escaping or declaration (i.e. \n or \r is not needed to create a new line). Here in the below example, a welcome message is defined:

var welcomeMessage = """
        Welcome to log scanner!

        Enter the phrase you want to search for and the directory you want to search in.
           
           Author: Billy Korando
        """;

System.out.println(welcomeMessage);

Which when printed to the console looks like this:

Welcome to log scanner!

Enter the phrase you want to search for and the directory you want to search in.

   Author: Billy Korando
   

Note that along with the line breaks, even the indentation of Author: Billy Korando is preserved. For more information on how to use Text Blocks including rules around incidental whitespace, be sure to checkout the Programmer’s Guide to Text Blocks.

Declaring Variables with var

In the previous example, you might had noticed that though welcomeMessage is a String, it’s declare with var. This is an example of Local-Variable Type Inference, a feature added in Java 10, (JEP 286. This changed allows local variables, variables declared within the scope of a method body, to be decalred with var instead of a type. Like in this example:

var logStatements = new ArrayList<LogStatement>();

In the above example the actual type is ArrayList<LogStatement>, which is clear enough from the new ArrayList<LogStatement>() on the right side of the assignment operator. Instead of having the declare logStatements as ArrayList<LogStatement> (or more commonly, List<LogStatement>), logStatements can simply be declared with the var and the compiler can infer the type.

While not significant, var can be helpful to reduce some of the cermony when declaring variables in Java when it’s clear what the type should be like in the above example. The addition of var also brings Java into line with many other statically typed languages like C++, C#, Go, and others, which all have a similar type inference capability. For more of recommendations on how to use var, be sure to check out the Local Variable Type Interface: Style Guidelines and the Local Variable Type Interface FAQ.

Creating Data Carriers with Record

Processing data is at the heart of computing, be it in an application or in a script. Often the first step in data processing is defining a data carrier to place data into. Here Java has seen a significant improvement with the introduction of Records, (JEP 395), as a part of Java 16.

Creating a simple data carrier class historically required quite a bit of ceremony in Java; a constructor, accessor(s), and if you were going to check the value of data carriers, implementation of equals() and hashCode(), toString() is also a common need for easy printing of the contents of a data carrier. While IDEs have traditionally supported code generation of these methods, defining a data carrier class within a method was impractical as defining; constructors, accesors, equals(), hashCode(), and toString() often required dozens of lines of code or more. Additionally if changes were made to the fields of a data carrier class, the code generation would have be manually re-run, which if skipped, is an opportunity for bugs to enter a code base.

Records were designed for the concise modeling of data as data in Java. In the example, the record LogStatement collects the file name and full log message that contains the phrase, from the file where the phrased being search for was matched:

record LogStatement(String fileName, String logMessage) {
}

var logStatements = new ArrayList<>();
for (Path p : logFiles) {
    String fileContents = Files.readString(p);
    Stream.of(fileContents.split("\n")).filter(l -> l.contains(searchTerm))
            .forEach(l -> logStatements.add(new LogStatement(p.getFileName().toString(), l)));
}

logStatements.stream().forEach(System.out::println);

While in this example, we are simply using the implementation of toString to print the results, we could, for example, also use the implementation of equals to count how many times to the same line is printed within a file.

Assigning Value with Switch Expressions

Switch Expressions, added in Java 14 (JEP 361), are a great way of more succicntly handling evaluations that have n paths. Here in the example below, a switch expression is being used to set the file extensions of the file(s) to be searched based on user input:

private static String selectFileType() throws IOException {
    String fileMessage = """
            Select the type of file you'd like to search:
            1. .log
            2. .txt
            3. .md
            Your selection: """;
    System.out.print(fileMessage);

    var fileTypeSelection = new BufferedReader(new InputStreamReader(System.in)).readLine();
    return switch (fileTypeSelection) {
    case "1" -> ".log";
    case "2" -> ".txt";
    case "3" -> ".md";
    default -> {
        System.out.println("Invalid selection");
        yield selectFileType();
    }
    };
}

With switch expressions, only code to the right of the -> is executed, so a break statement is not needed. This behavior also prevents accidental fall through, a common soure of bugs when using a switch statement.

A switch expression can return value as in the example above, which helps to make code more succinct by not requiring an assignment statement (i.e. var x = y;) in every applicable case. Switch expression can also execute code blocks, like in the default case, and a case block will need to end with a yield statement, if the expression is returning a value (or throw an exception).

Switch expressions are required to be exhaustive. In many cases this will often mean defining a default case will be required, but if all paths are covered by a case, as in this example using an enum, a default case is not required.

Conclusion

Project Amber has made considerable progress toward reducing verbosity in Java, helping to make Java a more practical choice for writing scripts in. Project Amber is an ongoing effort and new features in active development include Record Patterns and Array Patterns (JEP 405) and Pattern Matching for Switch (JEP 406), which build upon Records and Switch Expressions respectively. As these changes continue to be added to Java, this should continue making Java a more productive language to write in.

While this article has primarily focused on the language changes that have been introduced to Java in recent releases, there are active efforts ongoing in other areas. A new HTTP Client API was introduced in Java 11 (JEP 321) which support HTTP/2. Convience Factory Methods for Collections, added in Java 9 (JEP 269), allow for easy creation of Lists, Sets, and Maps, with the .of factory method.

One area we haven’t yet touched on is improving startup performance. When running the code examples in this series you might have seen a small, but noticable delay when executing the code with the java launcher. While less, there is still even a small delay when using the java launcher against a compiled class. In the next article, we will take a look at how to improve the startup performance of short running Java applications.

The full code can be found in my GitHub repo here: https://github.com/wkorando/scripting-with-java/tree/article-ii

Scripting with Java – Improving Approachability

Historically Java has not often been thought of as a scripting language. This belief stems from concerns of Java being a compiled language, being “too verbose”, and having poor startup performance.

Recent changes to the JDK and the Java ecosystem however have begun to address these concerns, which we will explore in this article series. In this first article we will focus on how a change introduced in Java 11 can allow Java to behave like a script language in certain cases. The second and third articles will focus on reducing language formality and improving startup performance respectively.

Why Script with Java?

There might the question of why write scripts with Java at all? There are a couple of answers to this question. The first, if your primary role is a Java developer, writing scripts in Java means you can immediately bring all the experience and knowledge as a Java developer with you when writing a script to simplify or automate a task.

The second is that Java offers an improved feature set, tooling, and structure when compared to scripting languages. While for very simple scripts this might not be relevant, or in the case of structure, a small burden, as a script increases in size, complexity, and particularly importance within your organization, the additional feature set, tooling, and structure that Java offers can be extremely beneficial.

Single Source File Execution

One barrier to using Java as a scripting language is that it’s a compiled language. Before Java can be executed, it needs to be compiled into bytecode. While this is still true, JEP 330, part of JDK 11, allows for single .java source file to be directly compilation and executed in a single command by the java launcher.

Script writing is often a tight development loop of write, execute, write, execute, and the additional step of compiling disrupts this development flow. By combining compile and execution into a single step by invoking the java launcher, this makes Java more approachable as a language for writing scripts.

There are several ways to use this feature, let’s take a look at each of them.

Executing with the java Launcher

The most straightforward way of executing a Java source code file is from the command line with the java launcher. From the directory where the source file is located, the code can be executed simply with:

java [source code file] [arguments]

In a “Hello Name” code example, executing it would look like this:

java HelloJava.java Billy

And produce this:

Hello Billy

Executing using shebang

Running a single Java source file can be further refined with a shebang #!.

At the top of the source file add a #! line that points to the bin directory of your JDK installation and the argument --source version, like below:

#!/path/to/your/bin/java --source 16

public class HelloJava {

    public static void main(String[] args) {
        System.out.println("Hello " + args[0]);
    }

}

Then modify the file to be executable:

chmod +x

And remove the .java file extension

This allows the Java file to be executed like a normal bash script:

./HelloJava Billy

Note: Shebang only works with Unix like systems. For use on a Windows OS either a tool like cygwin will need to be used, or Windows Subsystem for Linux.

Adding source file to PATH

For further convenience a Java source file, configured like the above with a shebang, can be added to a system’s PATH to allow for easy execution from any location on a system. The exact steps for adding a file to your system’s PATH will vary depending upon your OS, so here is a handy link on how to update your PATH for the OS you are using: What are PATH and other environment variables, and how can I set or use them?

Odds and Ends

Most Java developers are very familiar working with larger applications utilizing frameworks, build tools, and packaging applications in JARs or WARs. However when writing and executing Java applications as a script from the command-line there are a couple of things to remember.

Single Source File, Multiple Classes

Typically Java developers define only a single top level class within a source file for readability and consistency purposes, however defining multiple classes within a single source code file is entirely valid and can be done like in the below example:

public class MultipleClassesInSameFile {
    public static void main(String[] args) {

        System.out.println(GenerateMessage.generateMessage());
        System.out.println(AnotherMessage.generateAnotherMessage());
    }


}

class GenerateMessage {
    static String generateMessage() {
        return "Here is one message";
    }
}

class AnotherMessage {
    static String generateAnotherMessage() {
        return "Here is another message";
    }
}

When executed:

java MultipleClassesInSameFile.java 

Produces the following output:

Here is one message
Here is another message

Just keep in mind that while multiple classes can be located in the same Java source file, when compiled each class will have it’s own dedicated class file. So the above code example, when compiled will produce the following files:

MultipleClassesInSameFile.class
GenerateMessage.class
AnotherMessage.class

Note: This also applies to inner classes, a class defined within another class, which will have a class file name like this: OuterClassName$InnerClassName.class.

This will have relevance in the third article in the series when we look at improving performance of scripts written in Java.

Configuring classpath

When referencing classes that are not part of the JDK, you will need to configure the classpath to include the location of these classes. In the below example of using the RandomUtils class from the Apache Common Utils library:

import org.apache.commons.lang3.RandomUtils;

public class GetRandomNunber {

    public static void main(String[] args) {
        System.out.println(RandomUtils.nextInt());
    }

}

While the code is contained within a single file, attempting to execute with the java launcher will cause the following error:

GetRandomNumber.java:1: error: package org.apache.commons.lang3 does not exist
import org.apache.commons.lang3.RandomUtils;
                               ^
GetRandomNumber.java:6: error: cannot find symbol
        System.out.println(RandomUtils.nextInt());
                           ^
  symbol:   variable RandomUtils
  location: class GetRandomNumber
2 errors
error: compilation failed

Adding the commons-lang3-3.12.0.jar jar to the classpath resolves the error:

java -cp /path/to/commons-lang3-3.12.0.jar GetRandomNumber.java 

The classpath, along with other relevant JVM args, can also be defined in the shebang line:

#!/path/to/your/bin/java --source 16 -cp /path/to/commons-lang3-3.12.0.jar

Conclusion

Being able to directly execute a single source code file helps to make Java more approachable for scripting. No build tools, beyond the JDK itself, are needed, and the single step to execute the script helps with a rapid development cycle commonly desired when writing scripts.

In the next article we will take a look at how recent changes have reduced the formality and “verbosity” of the Java language, and how this helps when writing scripts.

The code for for article can be found on my GitHub repo here: https://github.com/wkorando/scripting-with-java/tree/article-i