Is Java a good FP language? Comparing Basic FP support part 2

Is Java a good FP language? Comparing Basic FP support part 2

Comparing Programming languages for essential FP language support: Is Java a good FP language?

This article continues the?last one on FP.


Introduction

As you may recall, while reading about how Bitly was architected, the topic of 62-based encoder/decoder came up for tokenization and URL shortening. Then I decided to write a 62-based encoder in Java on a whim.

For another project, I need to use Clojure, and I thought this would be an excellent exercise to do in Clojure. Since I am familiar with FP Scala, I decided to start with Scala. I created a functional Scala version of the based 62 encoder/decoder and then transliterated it to Clojure.

In the process of doing this, I first did a direct port of the procedurally written Java program into a procedural Scala version and then converted that into a Scala FP version which I used to get the flow right for the Clojure version.

Welp! I decided to write an FP Java version. Now Java has more support for FP constructs than it did before. Java added higher-order functions via the streams API and the flow API. Yet, it lacks things like Tuples and is missing many higher-order functions, making writing recursive functions and using higher-order functions difficult.

This first article will show a more classic Java approach (Java 9/Java 11). My plan is to write a follow-up article showing the advancements in Java 17 that make some of this pitfalls (but not all) less of a problem. The following article will cover Java 17 records and sealed classes and its new switch/pattern matching support, which I believe improves the FP experience.

Let's show the Java FP version and discuss (classic Java FP Java8/Java9/Java11).


Java version of the base 62 encoder/decoder

package main.functional;

import java.util.LinkedList;
import java.util.OptionalInt;
import java.util.stream.IntStream;

public class Base62FuncEncoder {

    public static void main(String[] args) {
        final long id = 12345678910L;
        final String strId = convertToEncodedString(id);
        final long newId = convertToLong(strId);
        System.out.printf("%d %s %d", id, strId, newId);
    }

    private static final char[] DIGITS = {
            //0    1    2    3    4    5    6    7    8    9
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            //10    11   12   13  14   15   16   17    18    19   20  21
            'A',    'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L',
            //22    23   24   25  26   27   28   29    30    31   32  33    34  35
            'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
            // 36  37  38   39   40   41    42    43   44  45   46    47
            'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l',           //Easy to add more characters if not using lookup tables.
            // 48  49   50   51   52   53   54   55  56   57   58  59   60   61   // 62   63, 64
            'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', // '*', '@', '$'
    };

    private static final String STR_DIGITS = new String(DIGITS);

    public static long convertToLong(final String strId) {
        return convertToLong(strId.chars());
    }

    public static String convertToEncodedString(final long id) {
        final StringBuilder builder = new StringBuilder();
        final int placeHolder = findStartBucket(id);
        final Acc results = accumulateDigits(new Acc(placeHolder, id, builder));

        final long bucketValue = powDigitsBase(1);
        final int digitIndex = (int) (results.acc / bucketValue);
        final long acc = results.acc - (bucketValue * digitIndex);
        appendSafe(builder, digitIndex);

        //Put the remainder in the ones column
        final int place1DigitIndex = (int) (acc % bucketValue);
        appendSafe(builder, place1DigitIndex);
        return builder.toString();
    }

    static class Acc { //Tuple of sorts
       final int placeHolder; final long acc; final StringBuilder builder;
        Acc(int placeholder, long acc, StringBuilder builder) {
            this.placeHolder = placeholder;
            this.acc = acc;
            this.builder = builder;
        }
    }

    static private Acc accumulateDigits(final Acc args) {
        if (!(args.placeHolder > 1)) {
            return args;
        }
        final long bucketValue = powDigitsBase(args.placeHolder);
        final int digitIndex = (int)(args.acc / bucketValue);
        return accumulateDigits(new Acc(args.placeHolder - 1, args.acc - (bucketValue * digitIndex),
                appendSafe(args.builder, digitIndex)));
    }
    private static long convertToLong(final IntStream chars) {
        //Reverse
        final LinkedList<Integer> stack = new LinkedList<>();
        chars.forEach(stack::push);

        final long[] reduced = stack.stream().map(ch -> new long[]{0L, 0, ch.longValue()}).reduce(new long[]{0L, 0, 0},
                (pos, item) -> {
                    final long acc = pos[0];
                    final int position =  (int) pos[1];
                    final char ch =  (char)item[2];
                    final long value = computeValue(ch, position);
                    return  new long[]{acc+value, position+1, ch};
                }
        );
        return reduced[0];
    }

    private static int findIndexOfDigitInTable(char c) {
        final int index =  STR_DIGITS.indexOf(c);
        if (index == -1)
        throw new IllegalStateException("Unknown char #" + c + "#");
        return index;
    }

    private static long computeValue(char c, int position) {
        final int digitIndex = findIndexOfDigitInTable(c);
        final long multiplier = powDigitsBase(position);
        return digitIndex * multiplier;
    }

    private static StringBuilder appendSafe(StringBuilder builder, int digitIndex) {
        if (digitIndex != 0) {
            builder.append(DIGITS[digitIndex]);
        } else {
            if (builder.length() > 0) {
                builder.append(DIGITS[digitIndex]);
            }
        }
        return builder;
    }

    private static long powDigitsBase( final long exponent) {
        return doPow(exponent, DIGITS.length);
    }

    private static long  doPow(final long exponent, final long base) {
        if (exponent == 0L) return 1L;
        return doPow(exponent - 1L, base) * base;
    }

    private static int findStartBucket(long value) {
        final OptionalInt i = IntStream.range(0, 15).filter(item -> value < powDigitsBase(item)).findFirst();
        return i.orElse(0);
    }
}        

Now let's compare that to the Scala version and discuss some pitfalls of Java FP programming.

Before we continue, there are a lot of FP libs for Java programming which might improve your experience, but this is more about discussing the Java essential support with no extra 3rd party libs. A good follow-up article would cover some of these FP libs for Java.

Java support for foldLeft/reduce

Java support for?foldLeft/reduce?is not great.

There is no equivalent of foldLeft in Java 8's Stream API. As others noted,?reduce(identity, accumulator, combiner)?comes close, but it's not equivalent (with Scala's)?foldLeft?because it requires the resulting type B to combine with itself and be associative (in other terms, be monoid-like), a property that not every type has. --StackOverFlow.

There are workarounds for sure. Also, Java does not have Tuples and does not support primitive types well with collections (Java 17 makes improvements which we will cover in the following article). Scala sure seems to, as does Kotlin, Rust, Python, etc. There are workarounds for a lot of these issues. But it does not just work out of the box.

Scala version of foldLeft/reduce

  def convertToLong(strId: String): Long = doConvertToLong(strId.toCharArray)
  
  private def doConvertToLong(chars: Array[Char]): Long = {
    val (acc, _) = chars.reverse.foldLeft(0L, 0) { (pos, ch) =>
      val (acc, position) = pos
      val value = computeValue(ch, position)
      (acc + value, position + 1)
    }
    acc
  }        


Java version of foldLeft/reduce

    public static long convertToLong(final String strId) {
        return convertToLong(strId.chars());
    }
    
    private static long convertToLong(final IntStream chars) {
        //Reverse
        final LinkedList<Integer> stack = new LinkedList<>();
        chars.forEach(stack::push);

        final long[] reduced = stack.stream().map(ch -> new long[]{0L, 0, ch.longValue()}).reduce(new long[]{0L, 0, 0},
                (pos, item) -> {
                    final long acc = pos[0];
                    final int position =  (int) pos[1];
                    final char ch =  (char)item[2];
                    final long value = computeValue(ch, position);
                    return  new long[]{acc+value, position+1, ch};
                }
        );
        return reduced[0];
    }        

First notice that there is no built-in support to reverse a stream and that we are forced to work with an IntStream (not a char stream) when dealing with a string.

    public static long convertToLong(final String strId) {
        return convertToLong(strId.chars());
    }
    
    private static long convertToLong(final IntStream chars) {
        //Reverse
        final LinkedList<Integer> stack = new LinkedList<>();
        chars.forEach(stack::push);        

Thus already code is added to reverse the stream order. There are other ways to do this, but you must find a way is the point.

Compare that to the simplicity of Scala.

  def convertToLong(strId: String): Long = doConvertToLong(strId.toCharArray)
  
  private def doConvertToLong(chars: Array[Char]): Long = {
    val (acc, _) = chars.reverse.        

The next issue is the jumps and hurdles that you have to go through to do an actual?reduce?operations because of the generics used in Java.

        final long[] reduced = stack.stream().map(ch -> new long[]{0L, 0, ch.longValue()}).reduce(new long[]{0L, 0, 0},
                (pos, item) -> {
                    final long acc = pos[0];
                    final int position =  (int) pos[1];
                    final char ch =  (char)item[2];
                    final long value = computeValue(ch, position);
                    return  new long[]{acc+value, position+1, ch};
                }
        );
        return reduced[0];        

Note how the Java code first maps the stream to a long array with three values. We could have used a tuple if Java had them in their stock lib or defined a helper class. To use a Tuple, again, you need another lib for that, and many 3rd party libs do this.

Choice is great, but not as great for something so basic and essential as?Tuples?and?reduce/foldLeft?for FP. The example has to use the?map?function because the Java?reduce?method is not equivalent to the Scala?foldLeft. This problem is because the?reduce?requires the resulting type B to combine with itself and be associative, a property that this use case does not have since we have to track the current update value and the position in the stack.

Again, with all humility, please share if you know of a better way to do this. It seems vanilla Java does not support this operation well for basic FP programming.


Recursive Java

Now let's cover the recursive operation.

    public static String convertToEncodedString(final long id) {
        final StringBuilder builder = new StringBuilder();
        final int placeHolder = findStartBucket(id);
        final Acc results = accumulateDigits(new Acc(placeHolder, id, builder));

        final long bucketValue = powDigitsBase(1);
        final int digitIndex = (int) (results.acc / bucketValue);
        final long acc = results.acc - (bucketValue * digitIndex);
        appendSafe(builder, digitIndex);

        //Put the remainder in the ones column
        final int place1DigitIndex = (int) (acc % bucketValue);
        appendSafe(builder, place1DigitIndex);
        return builder.toString();
    }

    static class Acc { //Tuple of sorts
       final int placeHolder; final long acc; final StringBuilder builder;
        Acc(int placeholder, long acc, StringBuilder builder) {
            this.placeHolder = placeholder;
            this.acc = acc;
            this.builder = builder;
        }
    }

    static private Acc accumulateDigits(final Acc args) {
        if (!(args.placeHolder > 1)) {
            return args;
        }
        final long bucketValue = powDigitsBase(args.placeHolder);
        final int digitIndex = (int)(args.acc / bucketValue);
        return accumulateDigits(new Acc(args.placeHolder - 1, args.acc - (bucketValue * digitIndex),
                appendSafe(args.builder, digitIndex)));
    }        

Now let's look at the Scala equivalent.

  def convertToEncodedString(id: Long): String = {
    val builder: List[String] = List()
    val placeHolder = findStartBucket(id)
    val results = accumulateDigits(placeHolder, id, builder)
    val bucketValue = powDigitsBase(1)
    val digitIndex: Int = (results._2 / bucketValue).toInt
    val acc = results._2 - (bucketValue * digitIndex)
    val newBuilder: List[String] = appendSafeToList(results._3, digitIndex)
    //Put the remainder in the ones column
    val place1DigitIndex = (acc % bucketValue).toInt
    val finalBuilder = newBuilder ++ List(DIGITS(place1DigitIndex).toString)
    finalBuilder.mkString("")
  }

  private def accumulateDigits(placeHolder: Int, acc: Long, builder: List[String]): (Int, Long, List[String]) = {
    if (!(placeHolder > 1)) {
      return (placeHolder, acc, builder)
    }
    val bucketValue = powDigitsBase(placeHolder)
    val digitIndex = (acc / bucketValue).toInt
    accumulateDigits(placeHolder - 1, acc - (bucketValue * digitIndex), appendSafeToList(builder, digitIndex))
  }        

Note the Scala version is a lot shorter because we don't have to define a class. We can use a tuple. Tuples are an essential part of doing FP programming.

Let's not pick on Java. Other languages also don't handle this well. But most do. JavaScript, Kotlin, TypeScript, Rust, and Python make this sort of basic FP seemless. Java (and Golang) do not make this so easy and intuitive. Java could do a lot better.

Note that the "builder" in Scala is an immutable list that is better for FP. The Java version uses a?StringBuilder?because working with immutable lists is not as easy in Java as Scala.


Conclusion

There are other caveats and gotchas. Vanilla Java has no support for tuples, and while it contains higher-order functions to work with streams and flow APIs, it is not as complete as Scala (or even JavaScript or Kotlin or Pyhton or Rust). Java is not even complete enough to do something as common as a reduce operation (plus a long list of missing higher-order functions which one might expect). Haskell inspired Scala's higher-order functions so Scala has a very complete set. The Haskell inspiration is why Scala support is so rich.

There is also little emphasis on side effect free coding in Java (although the Scala version does not employ this yet but that is for a future article). Other than maybe Golang, Java has the worst built-in support for essential FP of any language in which this example 62-base encoder/decoder was implemented. There are 3rd party Java libs that can help out a lot, but there are many choices, and it requires some mix and match with some experimentation. Base Java should get this right. Java 17 is an improvement for sure.

Judge for yourself, and see this?same 62-based encoder/decoder example implemented in JavaScript, Kotlin, TypeScript, Rust, Python, and Clojure. Only Golang has the worst built-in support for higher-order functions which we will cover in a later article. (There are 3rd party libs for Go Lang that you can use, so it is more of a lib support problem than a language support problem, and Go does support and encourage side effect free programming, so maybe I am a bit harsh. Perhaps Go Lang is only second to last and not last. Perhaps Java and Go Lang are tied last. I love Java, by the way, but in the context of FP this is an opinion. )

FP has many more features than higher-order functions and Tuples, but higher-order functions and Tuples are essential to the task. I rank Java 2nd to last place for supporting FP programming out of JavaScript, TypeScript, Kotlin, Rust, Python, and Golang. Don't get me wrong, I still program in Java and enjoy it. But given a choice, I'd pick Kotlin or Scala for the JVM any day.

I do believe that Java 17 moves the needle so stay tuned for that.

Article 1: Comparing essential FP support in mainstream programming languages (last article)

Article 2: Is Java a good FP language? (this article)

Next articles

Does Java 17 improve Java FP support?

Kotlin and basic FP

JavaScript and basic FP

TypeScript and basic FP

Python and basic FP

Is Go lang a good FP language?

Rust and basic FP

Perf tuning and performance of procedural vs. FP

FP and side effects: Avoiding side effects in different languages

Is FP always the most expressive? (I have a few examples of not using FP and the code being more clear and easier to maintain and follow, which should be fairly controversial for those who think FP is a religion instead of a great technique.)

The Scala code is quite pretty right up to the end and that ugly mkString call. I do kind of like your argument about tuples, not everything needs to be named, esp. if it's going to be alive for a nanosecond. But then, in the age of copilot et al do we care anymore? The other thing I want to try is the ADT capabilities of Java 17, to see if sealed classes and interfaces are enough to catch it up. Although they still don't have union or intersection types like Scala 3 and Typescript. Also, I am interested to see if I can do Java 17 w/out exceptions.

要查看或添加评论,请登录

Rick H.的更多文章

社区洞察

其他会员也浏览了