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

Class Modifiers: practical difference between interface and open (extending vs implementing) #1719

Open
Levi-Lesches opened this issue Jul 2, 2021 · 26 comments
Labels
feature Proposed language feature that solves one or more problems

Comments

@Levi-Lesches
Copy link

Levi-Lesches commented Jul 2, 2021

TL;DR Since implements is stricter than extends, and since the latter is more permissive than the former, allow implementing with open and adjust the proposal accordingly (see the table all the way at the bottom).

According to the modules feature spec:

  • It is a compile-time error for a class to appear in an extends clause outside of the module where the class is defined unless the class is marked open.

  • It is a compile-time error for a type to appear in an implements clause outside of the module where the type is defined unless the type is marked interface

Essentially, the difference I want to focus on is interface class (implement & construct) vs open class (extend & construct). I've always seen implements as a stricter form of extends, not an entirely separate feature. For example:

class A { /* Actual code here */ }

class B extends A { /* ... */ }
class C implements A { /* ... */ }

For all intents and purposes, both B and C can be used wherever an A is expected. In other words, they both have implementations for every member that A defines. The only difference between the two is that C must re-implement every member of A, whereas B is free to inherit any implementations A provides.

The way I see it, a definition that uses extends can be seen as a subset of a definition that implements, since B is guaranteed to have at least as many A members as C does, but no more. Since implements is sort of "more complete" than extends, it should be logical that anything you can do with extends can be done with implements, but not vice-versa. For example, "can we guarantee __ about this class and its relation to A?" Yes, if it implements A, but not necessarily if it extends A.

So why are they treated separately in the spec?

If someone writes their class:

open class A {
  void func1() { }
  void func2() { }
}

then others are free to extend A, but not implement it. But if implementing is only different from extending in that it's more complete, then can't a user do both?

class B extends A {  // might as well implement A at this point. 
  @override
  void func1() { }

  @override
  void func2() { }
}

Of course, the same is not true the other way around -- you cannot half-implement a class, that's just extending. So it makes sense that an author can say "you must implement all members before using this", but not so much that "you can only implement some members". To see how this is true, look at Dart today -- you can make all members abstract to emulate implements, but there is no concept of not allowing implements. Perhaps a useful example:

// files.dart, current syntax
abstract class FileSystem {
  String get folderName;
  void openFile() { /* I/O code here */} 
}
// my Flutter app
import "package:files/files.dart";

// case A, extend only
class MyAppFileSystem extends FileSystem {
  @override
  String get folderName => "My app & co";
}

// case B, implement for multiple environments
class AndroidFileSystem implements FileSystem {
  @override
  String get folderName => "/android/user/local/apps/my_app_and_co/";  // idk

  @override
  void openFile() { /* android-specific code */ }
}

class IphoneFileSystem implements FileSystem {
  @override  
  String get folderName => "/ios/user/apps/My_app_and_co";  // i know ios even less

  @override
  void openFile() { /* ios-specific code */ }
}

The author of FileSystem can prevent someone from only overriding some members (like MyAppFileSystem does) by marking the class interface, or making openFile abstract. But it doesn't feel right that the author can only allow case A but not case B (ie, forcing the consumer to only override some methods). If consumers can't do this, they'll switch to extends and make sure to extend every member, but without the compiler helping them.

Regarding the issue of breaking changes with implements: I believe the error (and most static errors) are there for the user's benefit, not to burden API authors. While I understand someone not wanting to be responsible for implements-related errors, it seems unreasonable that devs can't rely on Dart to make sure they implement every member if that's the choice they make. Remember, a class that uses implements incorrectly can introduce compiler errors, while a class that emulates implements with extends can only make logical errors by using the wrong implementation.

I often think of new features as "how can someone opt out of this if they want?" With final, it's var, nnbd has ? and late, type-safety has dynamic, but open class doesn't allow a developer to implement a class, and instead forces them to use extends in an unsafe manner. What's worse is that, according to the spec doc, implements is more common than extends -- 9.77% vs 6.47% in Google's code!

Because of all this, I think that open should allow implementing, and therefore:

interface abstract class	only implement
open abstract class		extend or implement

interface class 		implement or construct
open class 			implement, extend, or construct

open interface class 		deprecated and replaced with open class
open interface abstract class	deprecated and replaced with open abstract class
@Levi-Lesches Levi-Lesches added the feature Proposed language feature that solves one or more problems label Jul 2, 2021
@lrhn
Copy link
Member

lrhn commented Jul 2, 2021

I see implements and extends as different, not degrees of the same thing.
The purpose of extends is to inherit implementation, and the purpose of implements is to share an interface without sharing implementation.

There are things you can do with extends that you cannot with implements (inherit implementation!).
There are things you can do with implements that you cannot with extends (not a lot, since if you can extend, you can override all methods from the superclass, except private methods from other libraries. The big thing you get is that you don't need to call any super-constructors, so you can implement a class with no public generative constructors).

It's reasonable to allow extends and not implements in order to ensure that all classes of that type share the same implementation (possibly private implementation). That's actually the purpose of extensible classes in languages where classes and interfaces are separate. Forcing a subclass to inherit implementation is a purpose in itself (and if you can declare methods final, so they can't be overridden, it's even more valuable).

It's also reasonable to have a default implementation that you don't want people to reuse, but to also allow other independent implementations of the interface, so exposing a class that you can only implement, but not extend, is also useful. (Probably not as useful as the other direction, but useful nevertheless).

@eernstg
Copy link
Member

eernstg commented Jul 2, 2021

I agree on the general statement that extends and implements are just different. Here's a biggie: implements smoothly allows for having multiple superinterfaces.

With extends we only get one, and languages where you can extend multiple classes (like C++ and many others) generally struggle with the complexity of handling overlapping sets of implementations from those multiple superclasses. So that's a non-trivial addition to

not a lot

;-)

@Levi-Lesches
Copy link
Author

Levi-Lesches commented Jul 2, 2021

The purpose of extends is to inherit implementation, and the purpose of implements is to share an interface without sharing implementation.

Right, and my argument here is that whenever it is possible to extend a class, it is always possible to implement it -- in the extreme case, by using extends and overriding everything -- so therefore the choice should be left to the consumer. Otherwise, people will use extends and manually override everything, but without compiler warnings if they missed something important. The same is not true the other way around, as you pointed out, so I agree that keeping interface is important.

There are things you can do with implements that you cannot with extends... you can implement a class with no public generative constructors

Huh, TIL. But it still fits the pattern that if you can extend a class, by definition it is possible to implement it (ie, making sure to override everything). This restriction of non-public generative constructive goes the other way around: you can implement it but not extend it. Again, the point isn't to say that extends and implements are the same, because they're not. Instead, I'm pointing out that any class that can be extended, can also be implemented. Since the burden of which keyword to use is being placed on API authors, there is no difference from their POV, so open should be used for both.

Forcing a subclass to inherit implementation is a purpose in itself (and if you can declare methods final, so they can't be overridden, it's even more valuable).

So I guess this is what I missed. Even with open abstract class, nothing is "forced" -- the consumer can just @override any member they want. However, with @nonVirtual from package:meta (or an equivalent built-in language feature like final), this can be forced. So how about we take it that one step further: allow a final modifier to mean "this member cannot be overriden", like @nonVirtual does today, and make it an error to mark such a class interface, or to implement it.

I do see the redundancy of allowing implements with open and then throwing an error if it turns out there's a final method, but I see it more as a logical conclusion: All classes that can be extended can be implemented by definition, unless they have a final method. Otherwise, there is no reason to exclude implementing. It's similar to the ability today to mark a public class abstract yet have no public generative constructor -- no one can extend it even though it's abstract.

exposing a class that you can only implement, but not extend, is also useful. (Probably not as useful as the other direction, but useful nevertheless).

I agree with this, and from the proposal spec it seems that it's actually more common to implement and not extend. That's why I think it's a good idea to keep the interface keyword -- it signifies "if you want to override this class, make sure you get everything", which is especially useful in the case of native code.

@Levi-Lesches
Copy link
Author

Here's a biggie: implements smoothly allows for having multiple superinterfaces.

So it sounds like you're saying that there's an advantage to using implements that cannot be acheived by using extends. Fully agree there, that's my motivation for not restricting implements when it should be allowed.

@lrhn
Copy link
Member

lrhn commented Jul 2, 2021

Even without final virtual declarations, you can't override everything from your superclass.
If a non-interface class has a private member, you can be sure anything of that type inherits that private member as well.

@Levi-Lesches
Copy link
Author

Levi-Lesches commented Jul 2, 2021

Can you?

// package1
class Private {  // "open interface class" in the new syntax
  void _log() => print("This is really important");
  void doSomething() => _log();
}
// package2
import "package:package1/package1.dart";

class Public1 implements Private {
  @override
  void doSomething() => print("No it's not");  // can't access _log from here anyway
}

class Public2 extends Private {
  @override
  void doSomething() => print("No it's not");  // can't access _log from here anyway
}

void main() {
  Private().doSomething();  // "This is really important"
  Public1().doSomething();  // "No it's not"
  Public2().doSomething();  // "No it's not"

  Public1()._log();  // error
  Public2()._log();  // error
}

When either extending or implementing, so long as I choose to override Private.doSomething, then Private._log is lost. Now, if Private.doSomething were final, that would be different, as it would ensure that doSomething actually uses _log.

@lrhn
Copy link
Member

lrhn commented Jul 2, 2021

That's not a non-interface class. If we have non-interface classes which cannot be implemented, you can't write class Public1 implements Private. That's the entire point of non-interface classes.

Try:

open class Foo {
  final String _foo;
  Foo(String foo) : _foo = foo;
  int get hashCode => _foo.hashCode;
  bool operator==(Object other) => other is Foo && _foo == other._foo;
}

That implementation of == is safe.
Sure, you can override it, but that's on you. The author of this code has done nothing wrong (and with the current implementable classes, the code would be wrong).

@Levi-Lesches
Copy link
Author

Levi-Lesches commented Jul 2, 2021

That's not a non-interface class

Right, I was using current syntax but consider Private to be an open interface class.

That implementation of == is safe.

Well, using implements doesn't force you to override ==, but I think you're saying what if a public member a relies on a private member _b to work properly? In this case, you're saying == can't work without _foo. My response is that if I choose to override a with my own logic, that's like saying I don't need _b. So if I did choose to override ==, it would be because I have another field I want to compare. Presumably, I don't even know about _foo, since it wouldn't be documented or exposed, so I wouldn't even know how to depend on it at all.

Sure, you can override it, but that's on you.

Exactly. And if I want to override everything, I would need implements to do so, so it should be my choice.

@lrhn
Copy link
Member

lrhn commented Jul 3, 2021

And you can do that with extends, but you can't not have the inherited _foo getter. Forcing you to use extends ensures that. Allowing you to use implements does not.
In this case it matters because the code on the existing Foo class would not be safe if you pass your implemented Foo knock-off to my == method.

@Levi-Lesches
Copy link
Author

class Foo {  // using today's syntax, so no "open" yet
  final String _foo;
  Foo(String foo) : _foo = foo;
  int get hashCode => _foo.hashCode;
  bool operator==(Object other) => other is Foo && _foo == other._foo;
}
class MyFoo implements Foo { }

void main() {
  Foo foo1 = Foo("h");
  Foo foo2 = Foo("h");
  MyFoo myFoo1 = MyFoo();
  MyFoo myFoo2 = MyFoo();

  print(foo1 == foo2);  // true
  print(myFoo1 == myFoo2);  // false
  print(myFoo1 == foo1);  // false
  /// Unhandled Exception. NoSuchMethodError: `MyFoo` has no instance getter `_foo`
  print(foo1 == myFoo1); 
}

Well I didn't know this doesn't work today. Shouldn't this be an error even today? I didn't realize simply implementing a class can give runtime errors! From #1006 (comment):

@eernstg:

Do implementers have to implement private members?

Yes, the basic rule is that it is an error if a subtype (implements, not extends) does not have an implementation of a member, private or public.

However, Dart used to be a much more dynamically checked language than it is today, and the treatment of missing private members across different libraries is a corner of the language where sound static checking has not (yet) been introduced, so there is indeed a potential for an error at run time which is not flagged at compile-time, as shown in @hugocbpassos' example here.

It wouldn't be difficult at all to flag this error at compile-time, but it is a breaking change, which is the main reason why it hasn't been done.

It would actually be a very good candidate for a lint, cf. dart-lang/sdk#58179.

So it seems this is only happening because catching it would be a breaking change... seems like module restrictions would be a good time to make that change then.

But in any case, it's a runtime error today (in "safe" Dart), and it can be made into a static error today, so I don't see why this should be considered unsafe. People use implements all the time and this edge case doesn't stop them. But if it's caught as a static error, won't that make it safe again? Then there's really no difference between extends and implements, since all implementers are forced to implement private members.

@munificent
Copy link
Member

I'm with Lasse and Erik in that I think implements is a very different kind of operation compared to extends and a library may want to support either, both, or neither. When a class C allows extends but not implements, it means that inside the code for the library where C is defined, if the library has an instance of C, it knows it has an actual instance of the library's own class C or a subclass of it. That could be useful because:

  • As Lasse showed, the library may be accessing private methods or fields defined on C. Those may not always be in overridable self calls.
  • The class C may be a subclass of some private superclass in the library that the library wants to safely cast the object to.
  • The class C may implement some other private interface in the library that the library wants to safely cast the object to.
  • The library may want to require that every instance of C has reliably invoked a generative constructor on C.

@Levi-Lesches
Copy link
Author

Levi-Lesches commented Jul 20, 2021

  1. That should be a compile-time error, per omit_local_variable_types conflicts with --no-implicit-dynamic sdk#57710. IMO, now would be a good time to make that breaking change. In trying to separate logic, it ends up adding a whole class of runtime errors. Besides, this needs a solution in today's Dart, modules aside.
// a.dart
class _A { bool get _condition => true; }
class B extends _A { }

void test(_A obj);
// b.dart
import "a.dart";
class C implements B { }

void main() {
  final B b = B();
  final C c = C();
  test(b);  // okay
  test(c);  // runtime error
}
  1. This already works with implements, unless I misunderstood you:
// a.dart
class _A { }
class B extends _A { }

void test(_A obj) { }
// b.dart
import "a.dart";

/// This analyzer knows about [_A] in this scope. It gives the error: 
/// Missing concrete implementation of `getter _A.condition`.
class C implements B { }

void main() {
  final B b = B();
  final C c = C();
  test(b);  // okay
  test(c);  // okay
}

Even adding a field to the private class doesn't change anything:

// a.dart
class _A {
  final bool condition;
  const _A(this.condition);
}

class B extends _A {
  B(bool condition) : super(condition);
}

void test(_A obj) { }
// c.dart
import "a.dart";

/// This analyzer knows about [_A] in this scope. It gives the error: 
/// Missing concrete implementation of `getter _A.condition`.
class C implements B {
  @override
  bool get condition => true;
}

void main() {
  final B b = B(true);
  final C c = C();
  test(b);  // okay
  test(c);  // okay
}

However, adding a private field (condition -> _condition) does give a runtime error, as per dart-lang/sdk#57710.

  1. Same as number 2 -- if B implements _A, and C implements B, then both B and C can be passed wherever an _A is expected. Again, notwithstanding private members.

  2. So long as all of the members are implemented in one way or another in the subclass, why should the library get to dictate that a certain method (or in this case, constructor) is called? Think of implements like an @override for constructors. If extends can override any member it wants, why should constructors be different? I agree that this is one of the cases where extends and implements diverge, but I see implements as a more complete type of overriding. Again, private members aside, the subclass constructor is expected to initialize all fields, and there's no expicit demand that any more logic be called (otherwise it should be defined as a method). What to do in the constructor should be left to subclasses. That's why extends is usually simpler to use correctly, but sometimes implements is needed, to avoid that initializing logic.

@munificent
Copy link
Member

Also (and the most important one):

  • It allows the maintainer of the base class to add new concrete methods without breaking users.

@Levi-Lesches
Copy link
Author

Levi-Lesches commented Jul 21, 2021

That's exactly why implementing should always be allowed!

// package:alarms/alarms.dart -- only works on Android!
open class AlarmsManager {  // cannot implement
  void playAlarm() => AndroidManager.playAlarm();
}
// my Flutter app for iOS
import "package:alarms/alarms.dart";

// I have to manually check out the documentation to see if I got everything
class iOSAlarmsManager extends AlarmsManager {
  @override
  void playAlarm() => iOSManager.playAlarm();
}

Now, if the package author adds scheduleAlarm (which is not a breaking change to them, because they decided they only want to support Android), my app will suddenly fail. Not at compile-time, but crash at runtime if that method is ever called! By deliberately using implements instead of extends, I am saying "Dart, I need to override every member of this class. Make sure I don't miss any, and if a new member is added, let me know." The choice to replace (and not inherit) the current implementation is entirely deliberate. This is completely compatible in every way (except for dart-lang/sdk#57710) with extends, except using implements conveys your intention to both the reader and the compiler. So yes, I agree that implements is "different" than extends, but I still stand by my claim that extending is just a subset of implementing.

Put another way, we try to avoid breaking changes because we want the people who use our packages to be happy. But by not allowing them to use implements, we're either 1) breaking their code at runtime without any static warning or major version upgrade, or 2) not allowing them to reasonably implement their feature. I don't see that as an improvement.

That being said, I'd for sure support an annotation like @doNotImplement that displays a lint like this class is not meant to be implemented and members can be added at any time, so long as the user can ignore it. In fact, if breaking changes are really a concern, I would also support keeping the interface stuff the way it is, and if a class isn't declared as an interface, have that message be shown as a warning, so it's by default (no annotation needed) and stronger than a lint. Again, so long as the user can ignore it if they choose. Otherwise, you end up with devs who use extends unsafely and end up with all sorts of implementation problems that the compiler can't help with.

@munificent
Copy link
Member

I am saying "Dart, I need to override every member of this class. Make sure I don't miss any, and if a new member is added, let me know." The choice to replace (and not inherit) the current implementation is entirely deliberate.

Yes, and if the class author gives you that capability by using interface, then everything is fine.

Put another way, we try to avoid breaking changes because we want the people who use our packages to be happy. But by not allowing them to use implements, we're either 1) breaking their code at runtime without any static warning or major version upgrade,

I mean, the maintainer of a class can change the body of any existing method and cause just as much breakage. When you're reusing someone else's code... you're reusing their code.

I think you're basically just arguing that people should be able to implement interfaces. I don't disagree with that. But I think class authors should be able to decide whether their class exposes an interface (just like they can in C#, Swift, Java, etc.). Your claim is that if an author doesn't want to expose an interface for their class, then they are also prohibited from allowing subclassing (even though C++, C#, Java, etc. let you do just that). I don't see the value in that restriction.

@Levi-Lesches
Copy link
Author

Levi-Lesches commented Jul 23, 2021

Your claim is that if an author doesn't want to expose an interface for their class, then they are also prohibited from allowing subclassing

Probably miscommunicated there -- I'm not saying anything about extending, I just want that in situations where you already can extend (ie, open), you should also be allowed to implement.

Yes, and if the class author gives you that capability by using interface, then everything is fine.

Right, and I want a way to override the author's intent. All of Dart's current safety features have overrides:

Mechanism Override
Type safety dynamic and as
Null safety ! and late
Variance covariant
Subtyping ommitting a generative constructor
Implicit inheritance implements
All lints and warnings // ignore

It feels wrong to take away a widely-used feature (more common than extends!) by default and not allow users to override that restriction.

@munificent
Copy link
Member

Your claim is that if an author doesn't want to expose an interface for their class, then they are also prohibited from allowing subclassing

Probably miscommunicated there -- I'm not saying anything about extending, I just want that in situations where you already can extend (ie, open), you should also be allowed to implement.

But you are saying something about extending. Your proposal is that if an author wants to allow extending but not implementing, they have no way to express that. You are removing the ability to express that intent.

Right, and I want a way to override the author's intent.

Sure, and the author might not want you to. We can't give 100% liberty to everyone any more than we can all eat the same ice cream cone.

All of Dart's current safety features have overrides:

You cannot construct a class marked abstract, construct a class that does not expose a public constructor, access a private field, extend a class that does not expose a public generative constructor, mixin a class with a non-default generative constructor, etc.

It feels wrong to take away a widely-used feature (more common than extends!) by default and not allow users to override that restriction.

No feature is being taken away here. Dart would still support implicit interfaces and classes would still be able to expose them. The primary reason we would default to non-interface is because the majority of classes (by a large margin) are not implemented as far as I can tell. It may be the case that even though most classes are not implemented, the author still intends to support that, but that's harder to measure. Even so, the data I have is that less than 10% of classes are implemented.

Defaults matter. Java is widely criticized for being verbose, but a big part of that is because users end up putting private on most of their members. Had Java picked a different default, that might be less of an issue.

@Levi-Lesches
Copy link
Author

All of Dart's current safety features have overrides:

I'll concede that "All" was an exaggeration. I meant "the features that API authors can use to lock down their code". abstract classes simply lack implementation and others may lack generative constructors, which is less about "locking down" and more about "this component simply can't perform these features". Private members (generative constructors and methods especially) have always felt off to me because of how many times I've tried to use something only to find it was hidden. But friend modules are currently redefining the bounds of private members, so that is still sort of up in the air.

Even so, the data I have is that less than 10% of classes are implemented.

According to the second section of data in the modules proposal, less than 10% of classes are being extended, and almost double the number of classes allow using implements than those that strictly allow extends:

total implements: 9.77 + 2.36 + 0.76 + 0.20 + 0.09 = 13.8%
total extends: 6.47 + 0.86 + 0.76 + 0.20 = 8.29%
implements without extends: 9.77 + 2.36 + 0.09 = 12.22
extends without implements: 6.47 + 0.86 = 7.33%

@munificent
Copy link
Member

According to the second section of data in the modules proposal, less than 10% of classes are being extended, and almost double the number of classes allow using implements than those that strictly allow extends:

total implements: 9.77 + 2.36 + 0.76 + 0.20 + 0.09 = 13.8%
total extends: 6.47 + 0.86 + 0.76 + 0.20 = 8.29%
implements without extends: 9.77 + 2.36 + 0.09 = 12.22
extends without implements: 6.47 + 0.86 = 7.33%

Oops, yes, you're right. Even so, this still points to me that non-extensible and non-interface are reasonable defaults since 13.5% and 8.29% are still both quite small numbers. Also, other languages don't support implicit interfaces at all. Swift and Kotlin seal by default. So there are existence proofs that you can have a thriving ecosystem with those defaults.

@cedvdb
Copy link

cedvdb commented Jul 27, 2021

The purpose of extends is to inherit implementation, and the purpose of implements is to share an interface without sharing implementation.

That's true for the current spec. Java has default methods in interface though.

@Levi-Lesches
Copy link
Author

Also, other languages don't support implicit interfaces at all. Swift and Kotlin seal by default.

That's true. I do like that Dart allows so much by default, and I'd love a way to maintain that, but keeping things in perspective, this certainly isn't as Earth-shattering as sound null safety or extensions and such.

@Levi-Lesches
Copy link
Author

I think that dart-lang/linter#2918 should solve the problem regarding subclasses not inheriting private members

@eernstg
Copy link
Member

eernstg commented Mar 16, 2023

The upcoming base modifier will ensure that any instance of a base class A declared in a library L will inherit from a class in L which is a subtype (at least, perhaps subclass) of A. This means that it can be detected locally that every class that may have subtypes outside L has a concrete implementation of every private member. In this case it is safe to call private members on instances of type A and below in L, both on the receiver this and on other receivers.

So dart-lang/linter#2918 is one trade-off, and base is another, and they are not directly comparable. I would tend to think that the approach using base is more flexible and more widely applicable, though.

@Levi-Lesches
Copy link
Author

So #2918 is one trade-off, and base is another, and they are not directly comparable. I would tend to think that the approach using base is more flexible and more widely applicable, though.

True, base and dart-lang/linter#2918 (disallowing unsafe private access) solve the same problem and therefore only one is needed, but I believe they are comparable and that dart-lang/linter#2918 would be the cleaner approach. As discussed in dart-lang/sdk#58179, there are several places where declaring a private member A._a can cause problems:

  1. In the declaration of A, since another class could reimplement it without the private members. This is solved by using base class A { } instead of class A { }, forcing all subclasses to at least inherit the private members. This would not solve the problem as someone could just as well forget or not think to use base.
  2. In a declaration class B implements A, since it will not inherit any of A's private members. Flag concrete classes with unimplemented private members sdk#58179 would warn or hint when you're missing concrete private members. This would also not solve the problem as it will just be an ignorable hint, but more importantly, there is nothing the author of B can do to fix the unsafe code. This will also cause false positives if the private members are only used internally.
  3. In any unsafe usage of A._a -- ie, not this._a -- since you can't guarantee that any subtype of A has _a. This would be disallowed by make outdir optional linter#2918. This would be like null safety in that it will flag all unsafe code, even if it might work today. This is also the only option where there is a clean and safe solution: move the code inside of A so it can be reimplemented when needed (this is possible since it is guaranteed to be in the same library as A).

Additionally,

  1. People may be inclined to use base when not needed, instead of writing safer code that doesn't assume anything about subtypes of A. OOP principles would say that any subtype of A should be interchangeable with an A, but base would restrict that and force us to make assumptions about implementation details, and lead to fewer people being able to reimplement A, even when it would otherwise be safe.
  2. The differences between base and interface greatly complicate the class-modifiers proposal, and implementing a solution that removes the need for base means the number of modifiers combinations decreases dramatically. On top of that, interface and base are both negative modifiers, which I see @leafpetersen agrees makes them harder to reason about. If implementing were always safe, then there could be just interface, which allows the ability to extend and implement at the same time.

Overall, I'm happy that the Dart team agrees this unsafe private member access is a problem that needs to be solved (as the root cause behind dart-lang/sdk#57710 is not exactly recent), but I would hope that the solution is cleaner in the long term, and that future features aren't made more complicated by it.

@Levi-Lesches Levi-Lesches changed the title Modules: practical difference between interface and open (extending vs implementing) Class Modifiers: practical difference between interface and open (extending vs implementing) Mar 16, 2023
@Levi-Lesches
Copy link
Author

Also, since I just realized this issue was initially opened for the modules proposal, I'm happy to open a new one to specifically discuss base vs dart-lang/linter#2918 in the context of the class-modifiers proposal instead.

@eernstg
Copy link
Member

eernstg commented Mar 17, 2023

solve the same problem and therefore only one is needed

Well, "private to this" and "subtypes will inherit from this library" are so different that they might well coexist.

If a given member __m is private to this then the code using __m would need to be structured in a way that makes this possible. For instance, it might be inconvenient that we can't use this member during traversal of a collection, but we just have to write the code such that we won't need that. On the other hand, "private to this" doesn't impose any constraints on clients outside the declaring library, which is a very useful property.

Conversely, a base class/mixin A in a library L puts some strong constraints on subtypes of this class/mixin in other libraries: They must extend or mix in rather than implement. This implies, for example, that the creation of an instance of a subtype of A will always run some code in a generative constructor in L, which again means that L can "keep an eye on" all instances of type A. Of course, you can override any method (that you can denote, i.e., not private ones), and you can probably cheat an assert(...) in a generative constructor in L, no matter what you write in there. But assuming that we're aiming to catch bugs rather than fight adversaries, this does give some support to the description "if A is base then any object of type A is one of our guys".

Not the same thing! ;-)

there are several places where declaring a private member A._a can cause problems:

Let me comment on this in order to illustrate why base would suffice.

  1. In the declaration of A ... someone could just as well forget or not think to use base.

We'd certainly have a lint detecting the situations where A leaks (that is, it is public or has a public subtype, etc.), A._a exists, and _a is called on a receiver which is not this: "_a might not exist", and perhaps "consider making A a base class".

The lint could of course be strengthened to be an error, if we decide that the guaranteed existence of _a is so crucial that this situation must be handled (of course, the invocation could still be performed dynamically).

  1. In a declaration class B implements A

If we do use base class A then it must be base-or-final-or-sealed class B implements A.

If this occurs in the same library as the declaration of A then B can and must have all members of A which are private to this library. If it occurs in a different library then implements A is just an error, with any class modifier.

  1. In any unsafe usage of A._a -- ie, not this._a

This makes no difference, I did not assume that every invocation of A._a in bullet 1 would be on this.

People may be inclined to use base when not needed

That's true, we as a community need to have a discussion about the new concepts (for any new feature, but especially for the ones that have real novelty, like class modifiers), such that we understand how to use them well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Proposed language feature that solves one or more problems
Projects
None yet
Development

No branches or pull requests

5 participants