Skip to content

Introduce method contracts #757

@sdeleuze

Description

@sdeleuze

With projects like Spring, Micrometer and many others starting to leverage JSpecify combined with a nullness checker, there is a very popular ask (both from Spring commiters and from community members) that we need parameter contracts to be able to achieve 0 checker errors (green build) without too much annotations like @SupressWarnings("NullAway"). The scope of JSpecify 1.0.0 does not allow to reach that IMO reasonable goal, leading to the proliferation of multiple variants of @Contract (JetBrains one, Spring one) annotation + requiring special support in the various checkers. This is not ideal.

Method level @Contract leverages its own expression language, and my understanding is that a majority of members of the JSpecify working group (including me) want to avoid such expression language. I tend to think we could replace the method level @Contract annotation with an expression language by a parameter level annotation with enums. It would cover the majority of the use cases, even if not 100%.

I am wondering if it could make sense for JSpecify to introduce a new annotation like @ParameterContract (name to be refined, hopefully with a shorter one), with no retention at runtime, defined as follow:

@Documented
@Target(ElementType.PARAMETER)
public @interface ParameterContract {
	Condition when();
	Effect then();
}

public enum Condition {
	NONE,
	IS_TRUE,
	IS_FALSE,
	IS_NULL,
	IS_NON_NULL
}

public enum Effect {
	RETURN_TRUE,
	RETURN_FALSE,
	RETURN_NULL,
	RETURN_NON_NULL,
	RETURN_SAME_NULLNESS,
	FAIL
}

So

@Contract("null -> false")
public static boolean hasLength(@Nullable String str) {
	return (str != null && !str.isEmpty());
}

Would translate to:

public static boolean hasLength(@Nullable @ParameterContract(when = IS_NULL, then = RETURN_FALSE) String str) {
	return (str != null && !str.isEmpty());
}

I made the exercise with https://github.com/spring-projects/spring-framework to see how far we could go with @ParameterContract, and it seems to cover 97.5% of our use cases which is IMO pretty good. We could live with a couple of remaining @SupressWarnings("NullAway") for the few remaining unsupported use case.

In details:

  • 32x @Contract("null -> false") -> @ParameterContract(when = Condition.IS_NULL, then = Effect.RETURN_FALSE)
  • 13x @Contract("null -> null") -> @ParameterContract(when = Condition.IS_NULL, then = Effect.RETURN_NULL)
  • 12x @Contract("null, _ -> fail") -> @ParameterContract(when = Condition.IS_NULL, then = Effect.FAIL)
  • 8x @Contract("null -> null; !null -> !null") -> @ParameterContract(when = Condition.NONE, then = Effect.RETURN_SAME_NULLNESS)
  • 6x @Contract("null, _ -> false; _, null -> false") -> @ParameterContract(when = Condition.IS_NULL, then = Effect.RETURN_FALSE)
  • 5x @Contract("_, null -> fail") -> @ParameterContract(when = Condition.IS_NULL, then = Effect.FAIL)
  • 5x @Contract("null -> true") -> @ParameterContract(when = Condition.IS_NULL, then = Effect.RETURN_TRUE)
  • 5x @Contract("null, _ -> false") -> @ParameterContract(when = Condition.IS_NULL, then = Effect.RETURN_FALSE)
  • 4x @Contract("_, null -> false") -> @ParameterContract(when = Condition.IS_NULL, then = Effect.RETURN_FALSE)
  • 4x @Contract("_, null, _ -> fail") -> @ParameterContract(when = Condition.IS_NULL, then = Effect.FAIL)
  • 4x @Contract("false, _ -> fail") -> @ParameterContract(when = Condition.IS_FALSE, then = Effect.FAIL)
  • 3x @Contract("_ -> fail") -> @ParameterContract(when = Condition.NONE, then = Effect.FAIL)
  • 2x @Contract("!null, _ -> fail") -> @ParameterContract(when = Condition.IS_NON_NULL, then = Effect.FAIL)
  • 2x @Contract("null -> fail") -> @ParameterContract(when = Condition.IS_NULL, then = Effect.FAIL)
  • 2x @Contract("null, _ -> param2; _, null -> param1") -> Not covered (no support for returning parameter values)
  • 1x @Contract("_, !null -> fail") -> @ParameterContract(when = Condition.IS_NON_NULL, then = Effect.FAIL)
  • 1x @Contract("_, false -> fail") -> @ParameterContract(when = Condition.IS_FALSE, then = Effect.FAIL)
  • 1x @Contract("_, null -> true") -> @ParameterContract(when = Condition.IS_NULL, then = Effect.TRUE)
  • 1x @Contract("_, null -> true; null, _ -> false") -> @ParameterContract(when = Condition.IS_NULL, then = Effect.FAIL)
  • 1x @Contract("_, null, _ -> fail; _, _, null -> fail") -> @ParameterContract(when = Condition.IS_NULL, then = Effect.FAIL)
  • 1x @Contract("_, null, null -> fail") -> Not covered (no support for contract with conditions involving multiple parameters)
  • 1x @Contract("_, true -> fail") -> @ParameterContract(when = Value.TRUE, then = Effect.FAIL)
  • 1x @Contract("null, _ -> false; _, null -> false;") -> @ParameterContract(when = Condition.IS_NULL, then = Effect.RETURN_FALSE)
  • 1x @Contract("null, _ -> null") -> @ParameterContract(when = Condition.IS_NULL, then = Effect.RETURN_NULL)
  • 1x @Contract("null, _ -> null; _, null -> null") -> @ParameterContract(when = Condition.IS_NULL, then = Effect.RETURN_NULL)
  • 1x @Contract("null, _ -> true") -> @ParameterContract(when = Condition.IS_NULL, then = Effect.RETURN_TRUE)
  • 1x @Contract("null, _, _ -> null") -> @ParameterContract(when = Condition.IS_NULL, then = Effect.RETURN_NULL)

Any thoughts?

Metadata

Metadata

Assignees

No one assigned

    Labels

    designAn issue that is resolved by making a decision, about whether and how something should work.new-featureAdding something entirely new for users, not improving what's therenullnessFor issues specific to nullness analysis.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions