Which might be the downsides of declaring Functional Interfaces as variables in Java?

Viewed 326

I have been experimenting with Functional Programming in Java for a while now, and noticed that I started to prefer the use of the @FunctionalInterface functions from the java.util.function package such as Functions, BiFunctions, UnaryOperators, Predicates, BiPredicates, etc. instead of simple private methods in my classes. I am aware that their application is much more recommend as to be passed as arguments to another function, and that's how I usually tend to use them, but I now just find them immediate and somehow better.

I in fact now tend to declare some of these as variables to be then used in my classes when needed.

I don't seem to find anywhere guidelines or cons on the use of these instead of simple methods.

So: are there downsides using them this way?

  • An example:

Why prefer:

private boolean foo(final int a, final int b){
    return a < b;
}

instead of:

private final BiPredicate<Integer,Integer> foo = (a,b) -> a < b;

an example of how I tend to use them from my latest project:

    private final BiFunction<BoardPosition, Pair<Integer, Integer>, BoardPosition> sumBoardPosWithPair = (pos,
            pair) -> new BoardPositionImpl(pos.getX() + pair.getX(), pos.getY() + pair.getY());


    private final Function<Pair<Integer, Integer>, UnaryOperator<BoardPosition>> unaryCreator = (
            axis) -> (p) -> this.sumBoardPosWithPair.apply(p, axis);
    /**
     * If you need to call the fromFunction method twice for specular directions use
     * this TriFunction specularNoLimitDirection instead.
     */
    private final TriFunction<Piece, Vectors, Board, Set<BoardPosition>> specularNoLimitDirection = (piece, axis,
            board) -> Stream.concat(
                    this.fromFunction(this.unaryCreator.apply(axis.getAxis()), piece, board,
                            board.getColumns() + board.getRows()).stream(),
                    this.fromFunction(this.unaryCreator.apply(axis.getOpposite()), piece, board,
                            board.getColumns() + board.getRows()).stream())
                    .collect(Collectors.toSet());

    protected final Set<BoardPosition> fromFunction(final UnaryOperator<BoardPosition> function, final Piece piece,
            final Board board, final int limit) {
        /*
         * The "function.apply" at the seed of the Stream.Iterate is used to skip the
         * first element, that's itself, in fact a piece can't have as a possible move
         * it's original position.
         */
        final List<BoardPosition> positions = Stream.iterate(function.apply(piece.getPiecePosition()), function)
                .takeWhile(board::contains)
                .takeWhile(x -> board.getPieceAtPosition(x).isEmpty()
                        || !board.getPieceAtPosition(x).get().getPlayer().equals(piece.getPlayer()))
                .limit(limit).collect(Collectors.toList());

        final Optional<BoardPosition> pos = positions.stream().filter(i -> board.getPieceAtPosition(i).isPresent()
                && !board.getPieceAtPosition(i).get().getPlayer().equals(piece.getPlayer())).findFirst();
        /*
         * The sublist excludes the last n-th element of the high-endpoint, for this
         * reason we need to add 1.
         */
        return pos.isEmpty() ? new HashSet<>(positions)
                : new HashSet<>(positions.subList(0, positions.indexOf(pos.get()) + SINGLE_INCREMENT));
    }
3 Answers

You should do what feels most readable and maintainable. If putting these functions in variables with descriptive names feels like a readable and maintainable way to solve the problem, that's fine. There's nothing wrong with programming like this.

A middle ground that you might also like is putting the logic in ordinary static methods:

private boolean foo(final int a, final int b){
   return a < b;
}

and then referring to it when needed with a method reference: MyClass::foo. This is equivalent in behavior to the lambda you defined.

There's lots of ways to write this code. Everyone has opinions about the "right" way to do it, but in reality there are plenty of "right" ways. (And some not-so-right ways too.)

The high level criteria for deciding things like this should be Correctness, Readability and Performance ... with appropriate weighting depending on the project.

Q: So how do we "apply" those criteria here?

A: It is difficult ...

The problem is that choosing lambdas versions function references is so far down at the implementation level that its impact will be almost negligible. (Even assessing the efficiency at that level is probably irrelevant ... except in extreme cases.)

If we pull back to the broader question of functional (e.g. with streams) versus classical (e.g. with loops) it is still difficult to make general recommendations:

  • Correctness: the functional style is typically easier to reason about but that doesn't apply uniformly across all computing problems. (For example, to problems where efficient solutions require mutation.)

  • Readability: for simple problems, a functional / stream solution is often more readable. However, this isn't always the case. Sometimes the functional solution is necessarily convoluted. Sometimes a functional solution can be unnecessarily abstracted in an attempt to impose a general pattern across a range of problems or sub-problems.

  • Efficiency: there is no general answer here either. On the one hand, there will be little performance difference for well optimized code / solutions. On the other hand, it is easy to use streams (or classical approaches) naively. And it is easy for the (in-)efficiency of a given approach to be obscured by over-uses of abstraction ...


With respect to your example code: My primary concern as someone reading and maintaining that code is it is difficult to understand what it actually does. Maybe you (the author) understand it (now). But someone coming in cold is going to struggle. (Specifically, >>I<< am struggling ...)

The meta-problem is that readability is very difficult (and very expensive1) to assess objectively. So we are left with subjective assessment; i.e. just peoples' opinions.

A secondary concern is that if this is part of a game playing algorithm (an "AI" player) then efficiency could be critical to the success of the project. (Assuming that it is a serious project.)

But the flip-side is that if this code is just you "experimenting", then its is moot what other people think, whether they understand the code, or how it performs.


Some musings ...

This relates to the old top-down versus bottom-up design debate. Both approaches can work. Both approaches can fail. One of the failure modes for bottom up is a tendency to build a bunch of complicated abstracted infrastructure "because you think you are going to use it". Then when you actually get to use it, you may discover that either it isn't right ... or you could have just done it more simply without the abstraction.

I have also noticed that some smart programmers go through a "phase" of developing their own toolkits of utility classes or frameworks or whatever ... with the thought that they will be able use them in their future projects. (It is hard to say what causes this, but it may be rooted in boredom.) Anyway, it doesn't scale well, over either time or team size. These artifacts have a tendency to turn into legacy "cruft" that is unloved by the team, and (eventually) regretted by the author. Especially if the author has the chutzpah to publish the artifact and encourage others to use it. (Yet another dead project on GitHub!)


1 - You need to set up an experiment where a cohort of experienced programmers are given (say) 2 different solutions to the same problem, and they are required to read and answer questions about it, or modify it, or something. And then you repeat the process for different problems.

Java as we know is a fairly old programming language (to an extent) that has regular updates to stay ahead of the game when it comes to programming languages to choose from. Having said that, you may have been reading on old documentation explaining interfaces.

private boolean foo(final int a, final int b){
return a < b;

}

Is a valid function and one where you see in a lot in documentation and learning the fundamentals of interfaces.

Now you are comparing the "older" way to a much much "newer" way of handling methods/interfaces. With the release of Java 8, they added lambdas which I believe is to give programmers the ability to write methods with "less" lines and in a quicker fashion (among other things). You want your programming language to be up-to-date with security but also on par with others. Java is somewhat of a clunky programming language compared to say Python.

private final BiPredicate<Integer,Integer> foo = (a,b) -> a < b;

This is also an acceptable method/function to write in Java 8 and anything newer. There is no problem with writing functions in this manner and it may result in writing your code quicker.

The only problems with writing methods in this "newer" way is such

  1. You are using lambdas and some people may not have experience with lambdas and their powers
  2. Someone trying to read your code, may have trouble understanding it

There is nothing wrong with it, the "old" way is only preferred if you are apart of a team that says "No you cannot use lambdas". There is no difference.

Last note, you may find the benchmarks of lambda speed compared to anonymous classes interesting, this can be found here

[EDIT] I would like to add to only write lambdas for simple functions, I know they are very powerful but writing lambdas to search through a list then the conventional for loop is okay but complex methods should be written in a clear fashion. This is only to avoid headaches when debugging programs.

Related