Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow lambdas and method references referencing subclasses #4438

Open
wants to merge 2 commits into
base: master
Choose a base branch
from

Conversation

tkindy
Copy link
Contributor

@tkindy tkindy commented Jun 14, 2024

Background

In our company's codebase, we make heavy use of the popular Immutables library to generate value objects. Our typical style looks like this:

@Immutable
@HubSpotStyle
public abstract class MyImmutableIF {
  public abstract int property();
}

From this definition, Immutables would generate a MyImmutable value class extending MyImmutableIF, and an associated builder. We tend to reference the generated type (MyImmutable in this case) in consuming code rather than the abstract definition type (MyImmutableIF). In hindsight, that usage pattern does complicate some things, but we now have thousands of these value classes so we'll likely need to support this pattern for the foreseeable future.

One downside of this usage pattern is it tends to lead to the chance of a class initialization deadlock. For example, often immutable creators want to have some default instance, and they'll define that on their abstract type:

@Immutable
@HubSpotStyle
public abstract class MyImmutableIF {
  public static final MyImmutable FORTY_TWO = MyImmutable.builder().setProperty(42).build();

  public abstract int property();
}

This is obviously prone to a class initialization deadlock (since MyImmutable extends MyImmutableIF, and MyImmutable FORTY_TWO is a static field of MyImmutableIF assigned to during the class's initialization), and we've been bitten by them many times in the past using this pattern. This was the inspiration for my fix in #4429, and with that in place ClassInitializationDeadlock correctly detects these situations.

One way we've worked around the possibility of deadlocks in these situations is to defer the construction of the default value past class initialization time:

@Immutable
@HubSpotStyle
public abstract class MyImmutableIF {
  private static final Supplier<MyImmutable> FORTY_TWO = Suppliers.memoize(
      () -> MyImmutable.builder().setProperty(42).build()
  );

  public static MyImmutable fortyTwo() {
    return FORTY_TWO.get();
  }

  public abstract int property();
}

(Using Guava's Suppliers.memoize)

This is not prone to class initialization deadlock since, according to the JVM spec and real-world experimentation, MyImmutable won't be initialized when MyImmutableIF is initialized. MyImmutable will only be initialized once MyImmutableIF.fortyTwo() is called since that's the first time something on MyImmutable is accessed (assuming it hasn't otherwise already been initialized before that, of course).

Another common example in our codebase is holding some common function object that operates on instances of the value class:

@Immutable
@HubSpotStyle
public abstract class MyImmutableIF {
  public static final Comparator<MyImmutable> COMPARATOR =
      Comparator.comparingInt(MyImmutable::property);

  public abstract int property();
}

This is also not prone to class initialization deadlock; referencing methods does not initialize the class containing the referenced method (at least not with javac; I think it's possible for that to differ depending on how method references are compiled).

However, ClassInitializationDeadlock (with my previous fix) flags both of these situations, the first at the usage of MyImmutable.builder() inside the lambda passed to Suppliers.memoize, and the second at the reference to MyImmutable::property.

The fix

This PR updates ClassInitializationDeadlock to ignore lambda expressions and member references when looking for references that could enable a class initialization deadlock.

I recognize this isn't a perfect fix. Some lambdas or member references can actually be problematic. Here's a contrived example:

class A {
  static Object cycle = ((Supplier) B::new).get();
}
class B extends A {}

With this PR, Error Prone wouldn't flag that usage despite it being prone to a class initialization deadlock.

However, I can't think of any useful usages of lambdas or member references that would both enable a class initialization deadlock and wouldn't otherwise reference the other class in a way that would initialize it anyway. I'm sure we can come up with some examples, but at least in our codebase, most of the method references and lambdas the check is flagging are false positives.

Thanks for your consideration.

Comment on lines +358 to +371
@Test
public void subclassStaticMethod() {
testHelper
.addSourceLines(
"Foo.java",
"class A {",
" // BUG: Diagnostic contains:",
" static int value = B.value(); ",
"}",
"class B extends A {",
" static int value() { return 0; }",
"}")
.doTest();
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check's treatment of this situation is unchanged with this PR. I just noticed it wasn't tested and wanted to make sure it was still catching this usage while working on the check.

@cushon
Copy link
Collaborator

cushon commented Jul 16, 2024

Hi, thanks for raising this and for the suggested fix.

I think the code generated by here by immutables sounds similar to AutoValue. The check has a heuristic for AutoValue:

And there's a separate check to discourage usages of AutoValue that could lead to deadlocks: https://errorprone.info/bugpattern/AutoValueSubclassLeaked

Does any of that approach seem like it could be applicable to immutables?

All else being equal I think it'd be nice to avoid a blanket exemption for lambdas and method references. If there aren't other good options for immutables and we decided not to add that exemption by default, I suppose we could always add a flag to make the handling of lambdas and method references configurable.

What do you think?

@tkindy
Copy link
Contributor Author

tkindy commented Oct 17, 2024

Thanks for taking a look @cushon, and sorry for the super delayed response. 😄

The AutoValue use case is pretty similar to how we use Immutables at our company. However, it seems that AutoValue is more opinionated in how you use the defining type and the generated implementation such that it's reasonable to have checks to enforce that access pattern.

Our typical usage pattern with Immutables is to mainly reference the generated implementation's type except for factory methods, etc. that developers add on the defining type. Immutables has a style similar to AutoValue where you'd only reference the defining type, but it isn't used consistently (at least we don't use it that way).

I agree that completely ignoring lambdas and method references intuitively feels wrong. While these don't themselves trigger class initialization, their presence does mean there's a chance the overall statement will. I still believe this is unlikely to introduce many false negatives in practice, but the fact that there's the possibility at all is tricky.

If there aren't other good options for immutables and we decided not to add that exemption by default, I suppose we could always add a flag to make the handling of lambdas and method references configurable.

Out of curiosity, what would this look like? I'm relatively new to the codebase, but I haven't come across a configurable check before.

@tkindy
Copy link
Contributor Author

tkindy commented Nov 27, 2024

Hi again @cushon!

I had another idea here. What if we specifically exempted cases where the initializing expression was either a method reference or inside a lambda expression that's passed to Guava's Suppliers.memoize? As I noted earlier, that's our preferred fix in these situations, and checking for that explicitly should mean we don't increase the potential for false positives.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants