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

[draft] Generate 'sealed interfaces' for Conjure unions (feature flagged) #1838

Draft
wants to merge 38 commits into
base: develop
Choose a base branch
from

Conversation

iamdanfox
Copy link
Contributor

@iamdanfox iamdanfox commented Jul 12, 2022

Before this PR

For hackweek, I'm hoping to enable folks to use Java 17's pattern-matching-switch-expressions (https://openjdk.org/jeps/406) to visit the different possible variants of a Conjure union. My primary goal is to make code more readable, but I think it should come with a performance boost too.

After this PR

==COMMIT_MSG==
A new feature flag (--sealedUnions) allows users to opt-in to generating Java 17 sealed interfaces for their conjure unions.
==COMMIT_MSG==

Other related work is tracked on https://quip/3YEnAt26mmtE.

Outstanding TODOs:

  • don't require conjure-java to build and publish using a java 17 JVM.
  • Generate a throwOnKnown method
    • do we want to change from a SafeIllegalArgumentException now?
  • Generate a way to get the Known interface (maybe just optional initially?)
  • Name escaping of the 'Known' interface name
  • Testing

Possible downsides?

defaultImpl = UnknownWrapper.class)
@JsonSubTypes(@JsonSubTypes.Type(FooWrapper.class))
@JsonIgnoreProperties(ignoreUnknown = true)
public sealed interface SingleUnion {
Copy link
Contributor

Choose a reason for hiding this comment

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

There are a few things I might suggest changing:

  1. We could use this opportunity to become more opinionated about the visitor. It's a lot of code that becomes unnecessary with sealed type pattern matching. What if we drop the visitor entirely?
  2. We're exposing the *Wrapper types as public api, we can rename them dropping the Wrapper suffix to make them more concise. SingleUnion.Foo is pretty clear imo.
  3. Possibly a controversial take: What if we drop the static factories, and use standard records to implement the union type? We don't use constructors in most places, however these are wrappers around a single value (or a single key-value-pair in the case of unknown).

Copy link
Contributor Author

@iamdanfox iamdanfox Jul 12, 2022

Choose a reason for hiding this comment

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

I've got some open threads about some of these questions on https://quip/3YEnAt26mmtE - to summarise:

  1. what if we drop the visitor entirely? - as a long-term state, I definitely agree we should ditch the visitor. My thinking is that it'll probably be most efficient to keep generating it for now, to make the adoption of this flag as easy as possible.
  • I want to write the error-prone rule to rewrite visitors -> switch expressions. Then maybe when it's fully switched you over, it would flip another feature flag to stop generating these?
  • I had the tantalising thought that maybe if you have sourceCompat=17, we could actually just start generating these new sealed interfaces for everyone (because sealed interfaces are GA in 17, it's only the pattern-matching-switch-expressions that require the --enable-preview thing)... sadly I think revapi/ABI changes might derail this plan though.
  1. *Wrapper types - 100% agree - this is just a commit I haven't made yet.
  2. Use standard records to implement the union type - yep I'm planning on doing this too, especially because I think destructuring is coming which should make the switch statements even nicer https://openjdk.org/jeps/405.
  • drop the static factories I think I actually wanna keep them, because I find the IntelliJ autocomplete very natural when you type MyUnion. and then you get a nice dropdown of all the variants.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I also intend to add that .Known sub interface because i think the .throwOnUnknown ergonomics will be quite neat

Copy link
Contributor

Choose a reason for hiding this comment

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

Re dropping the visitor: I suppose it becomes a sequencing problem -- my preference is generally to reach for the aspirational goal, which we can set in new template projects, and follow-up with the sanest possible migration path.

Re jep405: I thought this was why you were opting into experimental features on jdk17, I don't think the sealed types and such need the experimental pattern matching support otherwise, no?

I find the IntelliJ autocomplete very natural when you type MyUnion.

Wouldn't that be equivalent to new MyUnion.? If we're using records, I'd prefer not to provide two public constructors for the same type, as that forces developers to decide between the two.

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 main reason I have been opting-in to --enable-preview is strictly to get https://openjdk.org/jeps/406 (pattern matching for switch). I only just discovered https://openjdk.org/jeps/405 (Record Patterns), but I think it'll be another usability benefit!

@iamdanfox iamdanfox force-pushed the dfox/sealed-interfaces branch from 9edbb1f to c4adf00 Compare July 12, 2022 18:24
@JsonIgnoreProperties(ignoreUnknown = true)
public sealed interface Union2 {

sealed interface Known permits Foo, Bar, Baz {}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
sealed interface Known permits Foo, Bar, Baz {}
sealed interface Known extends Union2 permits Foo, Bar, Baz {}

Then below we don't need to implement Union2, Known, only Known

Copy link
Contributor

Choose a reason for hiding this comment

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

Idea here is that we can use MyUnion.Known internally for business logic, ensuring that nothing has to validate against unknown variants, but no casting/validation is required to return or pass MyUnion.Known as a MyUnion.

We may be able to do something clever with safety annotations on MyUnion.Known that can't be guaranteed on the base type with an unknown variant (some issues today since we don't allow safety to be redefined as safer than a supertype, but I think it's reasonable in this instance, trick is ensuring we know when is safe, and when isn't)

Comment on lines +139 to +157
@JsonTypeName("baz")
record Baz(long value) implements Union2, Known {
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
public Baz(@JsonSetter("baz") @Nonnull long value) {
Preconditions.checkNotNull(value, "baz cannot be null");
this.value = value;
}

@JsonProperty(value = "type", index = 0)
@SuppressWarnings("UnusedMethod")
private String getType() {
return "baz";
}

@JsonProperty("baz")
public long getValue() {
return value;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Entirely untested, but I wonder if can do something like this:

Suggested change
@JsonTypeName("baz")
record Baz(long value) implements Union2, Known {
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
public Baz(@JsonSetter("baz") @Nonnull long value) {
Preconditions.checkNotNull(value, "baz cannot be null");
this.value = value;
}
@JsonProperty(value = "type", index = 0)
@SuppressWarnings("UnusedMethod")
private String getType() {
return "baz";
}
@JsonProperty("baz")
public long getValue() {
return value;
}
}
@JsonTypeName("baz")
@JsonAppend(attrs = @JsonAppend.Attr(propName = "type", value = "baz"), prepend = true)
record Baz(long value) implements Union2, Known {
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
public Baz(@JsonSetter("baz") @Nonnull long value) {
Preconditions.checkNotNull(value, "baz cannot be null");
this.value = value;
}
@JsonProperty("baz")
public long getValue() {
return value;
}
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Diff didn't render how I expected... The above suggests adding an annotation to the record: @JsonAppend(attrs = @JsonAppend.Attr(propName = "type", value = "baz"), prepend = true) and removing the getType() method.

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

Successfully merging this pull request may close these issues.

2 participants