Search code examples
javalambdafunctional-programmingfunctional-interface

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


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));
    }

Solution

  • 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.