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

Proposal: Track subtype exhaustiveness for classes with only private constructors #4032

Open
1 of 4 tasks
agocke opened this issue Oct 18, 2020 · 28 comments
Open
1 of 4 tasks

Comments

@agocke
Copy link
Member

agocke commented Oct 18, 2020

Private class constructor exhaustiveness

  • Proposed
  • Prototype: Not Started
  • Implementation: Not Started
  • Specification: Not Started

Summary

The switch expression already has a notion of exhaustiveness, and there are a variety of cases where switch arms can be confirmed to be exhaustiveness, as defined by reachability of the decision tree.

Right now the only state for reference types tracked for reachability is nullability, e.g. the following switch will not provide a warning about non-exhaustion:

int M(string? s) => s switch 
{
    string s2 => 0,
    null => 1
};

This proposal is to extend tracking to subtypes of classes with only private constructors. For example,

abstract class C
{
    private C() { }
    public class A : C { }
    public class B : C { }
}
int M(C c) => s switch
{
    A a => 0,
    B b => 1
};

The above should also produce no warnings, as the type checks in the switch expression exhaust all possible values of the input.

Unfortunately, the improvement as specified does have one backwards compatibility problem: it is an error if a switch arm is subsumed by a previous case. This would mean code which currently has a "catch all" pattern to avoid a warning in the above code would now be an error if the change were applied naively. The proposed fix for this is to not provide any warning or error if a case is unreachable only when arbitrary subclasses are not considered reachable.

This proposal should be an unalloyed benefit to programmers -- the compiler is simply smart enough to not provide incorrect warnings.

Design Meetings

@agocke agocke changed the title Proposal: Track exhaustiveness for classes with only private constructors Proposal: Track subtype exhaustiveness for classes with only private constructors Oct 18, 2020
@333fred
Copy link
Member

333fred commented Oct 19, 2020

Seems like this would roll in with the general sealed type hierarchy proposals.

@agocke
Copy link
Member Author

agocke commented Oct 19, 2020

Same idea, but also recognizing an existing pattern

@CyrusNajmabadi
Copy link
Member

int M(C c) => s switch
{
    A a => 0,
    B b => 1
};

Does this still need a null check?

--

Strongly in favor of this general area. Though on the fence if i think this special case is worth having support for, or if we should have the more general way to mark any hierarchy such that we can enumerate all the cases for it to do exhaustiveness checking

@agocke
Copy link
Member Author

agocke commented Oct 19, 2020

Does this still need a null check?

Imagine it's prefixed with #nullable enable and no, that doesn't, because c is not nullable.

@agocke
Copy link
Member Author

agocke commented Oct 19, 2020

Though on the fence if i think this special case is worth having support for

A different way of looking about it: this is a bug fix. It is simply true that any warning provided in the above switch expression is incorrect. The warning is that the switch expression is not exhaustive, but it is, in fact, exhaustive.

@YairHalberstadt
Copy link
Contributor

A different way of looking about it: this is a bug fix.

Not quite - this makes the number of subtypes part of the public contract, and adding new subtypes is now a breaking change where it wasn't before. I still think it's worth it, but it does have costs.

@agocke
Copy link
Member Author

agocke commented Oct 19, 2020

this makes the number of subtypes part of the public contract

I don't think this is really true, because it's a broader language design opinion at the level of the system, as opposed to a pure consideration of the warning itself.

The warning provided in this case is simply incorrect. The warning says that the switch is incomplete, but it isn't. The fact that a user changing their code can produce different behavior on the consumers part is true, but in the language we've never considered that as a "breaking change."

@YairHalberstadt
Copy link
Contributor

I'm not saying that this is a breaking change. What I'm saying is that till now, a library author can freely add subtypes to a class without introducing new warnings to consuming code. Now he can't.

In general if a library author cannot change something without introducing new warnings, that means this is a public contract, and in general it's best if public contracts are explicit, so authors don't accidentally tie themselves down. In this case I think authors probably would want to introduce new warnings in such a case, so it's worth it.

@agocke
Copy link
Member Author

agocke commented Oct 19, 2020

@YairHalberstadt I see, so you're talking about the broader, design-level question. As to the question of whether this would count as an "explicit" opt-in to the behavior, I would guess so.

But I don't think this negates from my fundamental point that 1) the current warning is incorrect and 2) it's clearly detectable by the compiler.

@333fred 333fred self-assigned this Jul 16, 2022
@333fred 333fred added this to the Working Set milestone Sep 26, 2022
@Richiban
Copy link

Doesn't the same exhaustiveness check apply to internal classes too (or to public types with internal constructors)? All their inheritors will be known at compile time, unless I'm missing something.

@FaustVX
Copy link

FaustVX commented Sep 29, 2022

@Richiban I don't think it's possible because of InternalsVisibleToAttribute

@Richiban
Copy link

Ahh, good point. It might be possible to detect that and treat any internal type defined in such an assembly as 'open' but it complicates the feature a fair bit. Sticking to private classes might be a good idea to start with.

@alrz
Copy link
Member

alrz commented Sep 29, 2022

This proposal is to extend tracking to subtypes of classes with only private constructors

The compiler already do something like this when it's immediately obvious:

interface I {
  sealed class A {
    void M(A a) {
      if (a is I) {} // won't warn if A is not sealed, but that would be still correct wrt effective accessibility
    } 
  }
}

Without any kind of modifier at the declaration-site to "verify" a closed hierarchy, I think it could get tricky to "infer" such relationship without a few false negatives, unless this is only about recognizing a specific pattern (like private constructors).

@alrz
Copy link
Member

alrz commented Sep 29, 2022

abstract class C
{
    private C() { }
    public class A : C { }
    public class B : C { }
}
int M(C c) => s switch
{
    A a => 0,
    B b => 1
};

Actually that should still warn unless A and B both are either sealed or define a private ctor. Question is how far the compiler should go to determine all possible subtypes.

@CyrusNajmabadi
Copy link
Member

Wouldn't that still be exhaustive @alrz ? Any further subclasses would have do drive from A or B, and both of those are already checked in the switch...

@HaloFour
Copy link
Contributor

@CyrusNajmabadi

Agreed, any subtype of A or B would be handled by that switch. This is how sealed hierarchies work in Java.

@alrz
Copy link
Member

alrz commented Sep 29, 2022

You're right, was thinking whether or not those could be inherited at all. That doesn't affect this particular switch, but that is also not exactly a closed hierarchy?

@alrz
Copy link
Member

alrz commented Sep 29, 2022

Guess as long as base types are constant it's already close- Is there any other language that allows non-flat DUs? None comes to mind.

@CyrusNajmabadi
Copy link
Member

It's closed on the sense that we can tell if they switch is exhaustive without needing a catch all clause :-)

@HaloFour
Copy link
Contributor

@alrz

Is there any other language that allows non-flat DUs? None comes to mind.

Java :)

@alrz
Copy link
Member

alrz commented Sep 29, 2022

@alrz

Is there any other language that allows non-flat DUs? None comes to mind.

Java :)

I mean, does it support exhaustiveness like what is described here?

@HaloFour
Copy link
Contributor

HaloFour commented Sep 29, 2022

@alrz

I mean, does it support exhaustiveness like what is described here?

Yes, it was an explicit goal of sealed classes:

Support future directions in pattern matching by providing a foundation for the exhaustive analysis of patterns.

And the pattern matching in switch expressions is in its third preview with Java 19:

public static String shape(Shape shape) {
    // this would be a compiler error if considered non-exhaustive.
    return switch(shape) {
        case Circle x -> "Circle";
        case Triangle x -> "Triangle";
        case Quadrilateral x -> "Quadrilateral";
    };
}

private sealed interface Shape permits Circle, Triangle, Quadrilateral { }
private static final class Circle implements Shape { }
private static final class Triangle implements Shape { }
private static sealed abstract class Quadrilateral implements Shape permits Square, Rectangle { }
private static final class Square extends Quadrilateral { }
private static final class Rectangle extends Quadrilateral { }

Add in record patterns, also in preview, and I believe this is the combination of features intended to solve for DUs in the Java language.

@HaloFour
Copy link
Contributor

I actually like the approach Java has taken by making the base type declare the legal subtypes rather than trying to determine it through analysis. It more clearly codifies the intent of the developer and the expected contract of the hierarchy. I would expect that if DUs came to the language with an enum-like syntax that would cover that intent, but maybe it should be considered for closed hierarchies too.

@alrz
Copy link
Member

alrz commented Sep 29, 2022

private sealed interface Shape permits Circle, Triangle, Quadrilateral { }

I think that depends on sealed interface there, not anything about constructors. Also it requires derived types to be final or sealed. Those are the modifiers I was referring to in my first comment.

What is proposed here is to infer subtypes from ctor accessibility which allows it to "light up" for existing code - which implies that it won't work for non-nested types. Again, Java supports that using those modifiers at the declaration-site. On the second thought, private protected should suffice. I think my point is, it feels like a hack rather than a feature since it's all implicit.

Java has taken by making the base type declare the legal subtypes rather than trying to determine it through analysis.

From the link, that seem to be optional but given the above requirements I think it's mostly predictable? Not sure if there's any restriction on where those subtypes can actually be declared.

@FaustVX
Copy link

FaustVX commented Sep 29, 2022

private sealed interface Shape permits Circle, Triangle, Quadrilateral { }
I know it's not C# related, but I find very weird to have the interface coupled with the class implementing it

@HaloFour
Copy link
Contributor

@alrz

From the link, that seem to be optional but given the above requirements I think it's mostly predictable?

That's true, it's optional in the cases where the subtypes are obvious, such as nested within the parent type. But you still have to explicitly specify that the parent type is a sealed/closed hierarchy and the legal subtypes are encoded in the metadata.

@alrz
Copy link
Member

alrz commented Sep 29, 2022

you still have to explicitly specify that the parent type is a sealed/closed

Looking closer, there's another restriction: (The subclasses may be auxiliary or nested classes.)

No such thing exists in C# so the scope is considerably broader. That should be fine, but I'd personally prefer it to be explicit rather than relying on a precise pattern to encode this semantics as a "side effect" of just having a particular accessibility on the constructor - it's too easy to accidentally break the contract.

@Richiban
Copy link

Will this fix also apply to any upcoming file visibility modifier?

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

No branches or pull requests

8 participants