Compile-time constants – conditional compilation and other applications

First of all, what exactly is a constant in Java? A constant in Java is rather an immutable variable – a variable that once initialized, is not allowed to change its value throughout its lifetime. You declare such a variable using final modifier and set it to its constant value using an initializer expression. Once initialized, its value is final i.e. can’t be changed. The initializer expression can be any type-compatible expression. However, the constant assumes a special significance when initialized with a special type of expression called a constant expression – an expression consisting solely of primitive type/String literals and/or other compile-time constants. The special status of such expressions comes from the fact that they get evaluated at compile-time by the compiler itself. So the value of final variables initialized with constant expressions is known at compile-time and hence they are called compile-time constants. Here are some examples:

final static int a = 5;
final static int b = a;
final static double c = 4.0 + 5.0 * 10.0;
final static double d = 10.0 * c;
final static boolean e = true;

All of the above variables are compile-time constants since they get initialized with constant expressions.

static int f = 10;
final static int g = f;
final static boolean h = method();
final static double i;
static { i = 100.0; }

None of the above is a compile-time constant: f is not declared final, the initializer expression for g is not a constant expression since (f is not a compile-time constant), h has been initialized with a method call (a method call is not a constant expression even if the method simply returns a literal) and i has not been initialized with an initializer expression (it is using a static initializer block). Lets look at another example with local final variables:

void myMethod()
{
    final int x = 10;
    final int y;
    y = 10;
}

In the above piece of code, y is a compile-time constant. z, however, is not a compile-time constant since it has not been initialized with an initializer expression. Note that the initialization using an initializer i.e. with an expression on the same line as declaration is an important condition for the variable to be treated like a compile-time constant by the compiler.

So we can now distinguish compile-time constants from others, but what’s their significance, you might be wondering. Well it’s simple: their value is evaluated at compile-time (as opposed to run-time). The compiler knows their value and it takes full advantage from it. It uses this information for many purposes which include, among others, making certain code optimizations and providing the ability to achieve conditional compilation. A summary of the most useful of these uses is given below. You can jump to a particular section from here, but a knowledge of all of these is nice to have.

1. Inlining

The compiler replaces all the references to such variables by the literal value itself. This is also referred to as inlining. For example, the code

class A
{
    static final int x = 100;
}

class B
{
    static void m()
    {
        int a = A.x;
    }
}

gets compiled into the following code (the bytecode equivalent of the following code, to be precise):

class A
{
    static final int x = 100;
}

class B
{
    static void m()
    {
        int a = 100; // A.x is compiled into literal 100
    }
}

You can easily verify this by decompiling the class file using a Java decompiler. This optimized code is obviously faster as it avoids run-time reference value resolution.

So far so good. Now the *caveat*: after compilation, any code that is referring to a compile-time constant will be referring to its value directly. If later you change the value of the original constant, the code that refers to the constant will continue to refer to the old value until it is recompiled. So all files that refer to the constant must be recompiled and just recompiling the file that contains the definition of the constant is not sufficient. Note that this is the default behavior, so you should be careful.

2. Code Reachability Analysis for while and for blocks

The compiler does a flow analysis of the code to make sure all code is reachable and duly flags any unreachable code as an error. While analyzing while and for code blocks, the compiler assumes all the code within the blocks to be reachable provided that the block itself is reachable. However, if the boolean condition in the while or for block is a compile-time constant or a constant expression, it is able to evaluate the condition and make a more nformed decision: if it evaluates to true, all’s well; if it evaluates to false, the code within the block is unreachable and is flagged as an error. Note that if the conditional expression is not a constant expression, the compiler can’t evaluate it and assumes the code within the block to be reachable, even if at runtime the condition evaluates to false.

void method()
{
    boolean cond = false; // not compile-time constant
    while (cond) // (1)
        doSomeStuff(); // (2) unreachable but assumed to be reachable
}

In the above code, the condition at (1) evaluates to false at runtime making the code at (2) unreachable. However, the compiler does not detect this and the code compiles.

void method()
{
    final boolean cond = false; // compile-time constant
    while (cond)
        doSomeStuff(); // unreachable; compilation error
}

Now the compiler can sniff out the unreachable code and wouldn’t let the code compile! We looked at the flow analysis for while and for. What about an if block? We’ll look at that next.

3. Conditional Compilation

You might be familiar with the way conditional compilation works in C/C++ programs using preprocessor directives – #define and #ifdef. In Java, however, there is no preprocessor involved in compilation process. So how do we achieve conditional compilation in Java?

The Java compiler provides direct support for conditional compilation with the help of constant expressions and compile-time constants. Do this: wrap the code that you want to compile conditionally inside an if statement, use a boolean compile-time constant or a constant expression for the conditional expression and, well, that’s about it! Based on the value – true or false of the condition, compiler will include or exclude the body of if statement in the output bytecode.

static final boolean DEBUG = false;
int add(int a, int b)
{
    if (DEBUG)
        System.out.println("Adding " + a + " and " + b); // (1)

    int result = a + b;
    return result;
}

We defined a compile-time constant called DEBUG and simply used it as an if condition. The compiler compiles the body of if conditionally. Since DEBUG is set to false, the compiler does not include line (1) in the compiled bytecode.

You might have expected the compiler to actually generate a compilation error on line (1) determining it to be unreachable – quite on the same lines as the treatment given to a while or for statement (see previous subsection). However, an if is treated differently than a while or for statement:

  • If the if condition is not a constant expression, the if statement is compiled as it is
  • If the condition expression is a constant expression, its value is considered
    • If the value is true, the entire if statement (alongwith the else clause if present) is replaced by the body of the if clause in the compiled bytecode
    • If the value is false, the entire if statement (alongwith the else clause if present) is replaced by the body of the else clause if an else clause is present or an empty statement if no else clause is present

Thus the above code gets compiled into bytecode equivalent to the following code:

static final boolean DEBUG = false;
int add(int a, int b)
{
    int result = a + b;
    return result;
}

If, however, DEBUG is changed to true, it will result in the following:

static final boolean DEBUG = true;
int add(int a, int b)
{
    System.out.println("Adding " + a + " and " + b); // (1)

    int result = a + b;
    return result;
}

As Java Language Specification puts it:

The rationale for this differing treatment is to allow programmers to define “flag variables” such as:

static final boolean DEBUG = false;

and then write code such as:

if (DEBUG) { x=3; }

The idea is that it should be possible to change the value of DEBUG from false to true or from true to false and then compile the code correctly with no other changes to the program text.

Note that the code that doesn’t make it to the compiled bytecode should still be valid compilable code otherwise compiler will give an error.

Usage scenarios:

Some of the common use cases for the use of conditional compilation are:

  • We want to use debugging or logging statements during development but don’t want them compiled into the production binary for example, because the size of the binary is a concern for us
  • We want to omit assertion related code from production binaries. Of course, we can enable or disable assertions while starting the application, however even when we disable the assertions, the assertion code is still present in the binary
  • We want to comment out a large chunk of code, but we can’t wrap it inside a /* */ style comment block because it already has some /* */ style comments and comments can’t be nested. We also don’t want to trouble ourselves with commenting each and every line with a // style comment

Note that the common use case for conditional compilation in C/C++ programs i.e. to compile based on platform being used isn’t required in Java since Java code should run on any platform without changes.

4. Definite Assignment Analysis

The Java compiler does code-flow analysis and makes sure that

  • whenever a final field or a local variable f is accessed, f is definitely assigned before the access; otherwise a compile-time error must occur
  • whenever there is an assignment to a final variable, the variable is definitely unassigned before the assignment; otherwise a compile-time error must occur

In doing this analysis for if, for and while statements, it takes only the structure of statements into account and ignores the values of expressions if the expressions are not constant expressions. However, when the expressions are constant expressions, it is able to make a more informed decision based on the value of the expressions. Let us look at some examples:

int weight = 10;
int price;
if(weight  50) price = 5000;
System.out.println("Price is: " + price); // (2) error

Compiler only looks at the structure of the statements and ignores the values of expressions since they are not constant expressions. It sees two ifs and can’t be sure that one of them will always be taken. It concludes that the variable price may not have been initialized before the access at (2) and produces an error, even though we can see that at run-time the if at (1) will be taken.

final int weight = 10;
int price;
if(weight  50) price = 5000;
System.out.println("Price is: " + price); // (2) compiles successfully

Now weight is a compile-time constant. Both the if conditions are constant expressions and the compiler is able to evaluate them to see that the if at (1) will be taken and so price will definitely get assigned before the access at (2). So the code compiles successfully.

5. Class Initialization

When a class is loaded and initialized, all static fields are first initialized to a default initial state based on their Java types. This is followed by the execution of field declarations and initializations in the order they appear in the class definition. During this phase, if the initializer expression of a field tries to read another field that has not yet been declared and initialized (for example through a this reference or via a method call), the referred field will be read in its default initial state.

Compile-time constants are, however, privileged. They are the first to get initialized among all declared class variables irrespective of their actual declaration order. This means that they get set to their initialized state before any other initializer is executed and gets a chance to read them. They never appear to be in the default initial state to any code. Lets consider the program below.

class A
{
    static int a = init(); // a = 0
    static int b = 10;
    static int init()
    {
        return b;
    }
}

In the above code, a is initialized by a method call to init which reads and returns the value of field b. b has not been declared/initialized at this moment (since its declaration appears after a) and so its value is the default initial value for int type i.e. 0. So a gets the value 0.

Now lets make b a compile-time constant by declaring it final:

class A
{
    static int a = init(); // a = 10
    static final int b = 10;
    static int init()
    {
        return b;
    }
}

Now b gets initialized to its value 10 before the rest of the non-final static fields’ initializers are executed and so its value always appears to be 10. So a gets the value 10.

6. Implicit Narrowing Type Conversions

Narrowing conversions usually require an explicit cast. However, when the right-hand side expression is a constant expression or a compile-time constant and certain other conditions are satisfied (for a full discussion, see this post), such conversions can happen implicitly without the need of an explicit cast. For example, the following code will compiles fine:

final int a = 10;
byte b = a; // (1) a is compile-time constant

In the above code, an implicit type conversion from int to byte takes place at (1) since a is a compile-time constant. If instead, a were not final, the conversion requires an explicit cast:

int a = 10;
byte b = (int) a; // a is not a compile-time constant

7. switch Labels

switch labels must all be constant expressions or compile-time constants. Since the compiler can evaluate these expressions, it enables it to make sure that all switch labels in a switch block are unique.

8. Evaluation of floating-point type constant expressions

All floating-point type constant expressions are evaluated by the compiler using FP-strict computations even if the context is otherwise non-FP-strict. This ensures that floating-point type compile-time constants are guaranteed to have the same exact value under different JVMs.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: