0

I need to modify a local variable inside a lambda expression in a JButton's ActionListener and since I'm not able to modify it directly, I came across the AtomicInteger type.

I implemented it and it works just fine but I'm not sure if this is a good practice or if it is the correct way to solve this situation.

My code is the following:

newAnchorageButton.addActionListener(e -> {
    AtomicInteger anchored = new AtomicInteger();
        
    anchored.set(0);
    
    cbSets.forEach(cbSet ->
        cbSet.forEach(cb -> {
            if (cb.isSelected())
                anchored.incrementAndGet();
        })
    );
        
    // more code where I use the 'anchored' variable...
}

I'm not sure if this is the right way to solve this since I've read that AtomicInteger is used mostly for concurrency-related applications and this program is single-threaded, but at the same time I can't find another way to solve this.

I could simply use two nested for-loops to go over those arrays but I'm trying to reduce the method's cognitive complexity as much as I can according to the sonarlint vscode extension, and leaving those for-loops theoretically increases the method complexity and therefore its readability and maintainability.

Replacing the for-loops with lambda expressions reduces the cognitive complexity but maybe I shouldn't pay that much attention to it.

Andrew Thompson
  • 168,117
  • 40
  • 217
  • 433
akmsw
  • 113
  • 4

3 Answers3

3

While it is safe enough in single-threaded code, it would be better to count them in a functional way, like this:

long anchored = cbSets.stream()     // get a stream of the sets
    .flatMap(List::stream)          // flatten to list of cb's
    .filter(JCheckBox::isSelected)  // only selected ones
    .count();                       // count them

Instead of mutating an accumulator, we limit the flattened stream to only the ones we're interested in and ask for the count.

More generally, though, it is always possible to sum things up or generally aggregate the values without a mutable variable. Consider:

record Country(int population) { }

countries.stream()
    .mapToInt(Country::population)
    .reduce(0, Math::addExact)

Note: we never mutate any values; instead, we combine each successive value with the preceding one, producing a new value. One could use sum() but I prefer reduce(0, Math::addExact) to avoid the possibility of overflow.

David Conrad
  • 15,432
  • 2
  • 42
  • 54
2

and leaving those for-loops theoretically increases the method complexity and therefore its readability and maintainability.

This is obvious horsepuckey. x.forEach(foo -> bar) is not 'cognitively simpler' than for (var foo : x) bar; - you can map each AST node straight over from one to the other.

If a definition is being used to define complexity which concludes that one is significantly more complex than the other, then the only correct conclusion is that the definition is silly and should be fixed or abandoned.

To make it practical: Yes, introducing AtomicInteger, whilst performance wise it won't make one iota of difference, does make the code way more complicated. AtomicInteger's simple existence in the code suggests that concurrency is relevant here. It isn't, so you'd have to add a comment to explain why you're using it. Comments are evil. (They imply the code does not speak for itself, and they cannot be tested in any way). They are often the least evil, but evil they are nonetheless.

The general 'trick' for keeping lambda-based code cognitively easily followed is to embrace the pipeline:

  • You write some code that 'forms' a stream. This can be as simple as list.stream(), but sometimes you do some stream joining or flatmapping a collection of collections.
  • You have a pipeline of operations that operate on single elements in the stream and do not refer to the whole or to any neighbour.
  • At the end, you reduce (using collect, reduce, max - some terminator) such that the reducing method returns what you need.

The above model (and the other answer follows it precisely) tends to result in code that is as readable/complex as the 'old style' code, and rarely (but sometimes!) more readable, and significantly less complicated. Deviate from it and the result is virtually always considerably more complicated - a clear loser.

Not all for loops in java fit the above model. If it doesn't fit, then trying to force that particular square peg into the round hole will take a lot of effort and almost always results in code that is significantly worse: Either an order of magnitude slower or considerably more cognitively complicated.

It also means that it is virtually never 'worth' rewriting perfectly fine readable non-stream based code into stream based code; at best it becomes a percentage point more readable according to some personal tastes, with no significant universally agreed upon improvement.

Turn off that silly linter rule. The fact that it considers the above 'less' complex, and that it evidently determines that for (var foo : x) bar; is 'more complicated' than x.forEach(foo -> bar) is proof enough that it's hurting way more than it is helping.

rzwitserloot
  • 85,357
  • 5
  • 51
  • 72
  • While I agree strongly that rewriting non-stream based code to use streams is quite unnecessary and often the wrong choice, all too often the "horsepuckey" comes from linters like SonarQube that are not very smart, and whose configuration is in the hands of higher ups in the corporate hierarchy. Sadly, simply turning off or changing the rule is often out of the hands of the developer. – David Conrad Mar 15 '22 at 22:02
  • @DavidConrad If that's true, that's a really dark view of the way 'corporate' dev teams work. I refuse to believe such utter lunacy is so set in stone that it isn't even worth _considering_ petitioning the team to change it. For what its worth, I mostly see developers who _think_ such feedback is doomed and only gets you demoted/fired, but where if they actually give it, the team acts to fix or even remove the overzealous tooling. As I would assume goes for almost all: That's very anecdotal. – rzwitserloot Mar 15 '22 at 22:22
  • Thank you so much for your answer. I really appreciate your advices, they're really helpful! – akmsw Mar 16 '22 at 22:11
1

I have the following to add to the two other answers:

Two general good practices in your code are in question:

  1. Lambdas shouldn't be longer than 3-4 lines
  2. Except in some precise cases, lambdas of stream operations should be stateless.

For #1, consider extracting the code of the lambda to a private method for example, when it's getting too long. You will probably gain in readability, and you will also probably gain in better separating UI from business logic.

For #2, you are probably not concerned since you are working in a single thread at the moment, but streams can be parallelized, and they may not always execute exactly as you think it does. For that reason, it's always better to keep the code stateless in stream pipeline operations. Otherwise you might be surprised.

More generally, streams are very good, very concise, but sometimes it's just better to do the same with good old loops. Don't hesitate to come back to classic loops.

When Sonar tells you that the complexity is too high, in fact, you should try to factorize your code: split into smaller methods, improve the model of your objects, etc.

QuentinC
  • 12,311
  • 4
  • 24
  • 37
  • Thanks! I'll try and split the method in smaller ones and see if that works (even tho this method isn't that long). I'll try not to worry so much with these alerts when they aren't obvious and the for-loops are working just fine. – akmsw Mar 16 '22 at 22:14
  • 1
    Remember that clearing all sonar messages isn't a goal. They are just helpful indication of what might be wrong somewhere in the code, but sometimes there are false positives or cases that don't need to be changed. Don't hesitate to discuss with your colleagues, what they think about. – QuentinC Mar 17 '22 at 05:23