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

Add proposal for BinaryCompatOnlyAttribute #7707

Closed
wants to merge 1 commit into from

Conversation

333fred
Copy link
Member

@333fred 333fred commented Nov 19, 2023

Adds a proposal for an oft-mentioned feature, BinaryCompatOnlyAttribute, that we've never actually gotten around to proposing before. @jaredpar for review

@teo-tsirpanis
Copy link

What about type-forwarding a BinaryCompatOnly type?

@333fred
Copy link
Member Author

333fred commented Nov 27, 2023

@teo-tsirpanis as currently proposed, you couldn't do that. May be another reason to look at the alternative version though.

@rickbrew
Copy link

If it's implemented as a flag for [Obsolete], as discussed in #7706, that should work. The ability to use [Obsolete] and [assembly: TypeForwardedTo(...)] was fixed with dotnet/roslyn#61264

@333fred
Copy link
Member Author

333fred commented Nov 27, 2023

If it's implemented as a flag for [Obsolete], as discussed in #7706, that should work. The ability to use [Obsolete] and [assembly: TypeForwardedTo(...)] was fixed with dotnet/roslyn#61264

Even if it used Obsolete, as currently worded, it would not work, as the type is literally invisible in the current compilation. We will have to see how hard a line we want to take. I will point out, @rickbrew, that if we did allow types and members to be visible in the current compilation, then your stated use case in #7706 (comment) would no longer be possible, as you would still be beholden to the overloading rules :)

@rickbrew
Copy link

Yeah I guess the compiler would also have to special case allowing it to be seen within uses of [assembly: TypeForwardedTo(...)]. I would definitely want to use all of these together though! Hopefully a good solution can be found to enable that.

@RikkiGibson
Copy link
Contributor

Is an error still issued when two members differ only by presence of BinaryCompatOnlyAttribute? Since the runtime can't distinguish which one you meant to call in that case?

class C
{
    [BinaryCompatOnly]
    public void M(int x) { }

    public void M(int x) { } // error?
}

## Summary
[summary]: #summary

We introduce a new attribute, `System.BinaryCompatOnlyAttribute`, that causes the type or member to which it is applied to be entirely inaccessible from source code.
Copy link
Member

Choose a reason for hiding this comment

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

Is it inaccessible from within the current compilation or just referencing compilations?

for any newly developed code. However, `ObsoleteAttribute` by itself is not enough. The type or member is still visible in overload resolution, and may cause unwanted overload
resolution failures when there is a perfectly good alternative, but that alternative is either ambiguous with the obsoleted member, or the presence of the obsoleted member causes
overload resolution to end early without ever considering the good member. For this purpose, we want to have a way to mark such members as technically being present in the DLL,
but not visible to any code, so that they will have no impact on member lookup, overload resolution, or any other similar elements.
Copy link
Member

Choose a reason for hiding this comment

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

Another motivation is that lacking a feature like [BinaryCompatOnly] we are limited in our ability to add new caller info style attributes. These two issues have good discussions on this: caller identity and xunit

The goal of these additions is to make it so that members marked with `BinaryCompatOnlyAttribute` are completely inaccessible to any location, they will
not participate in member lookup, and cannot affect the rest of the program. Consequentely, this means they cannot implement interface members, they cannot
call each other, and they cannot be overridden (virtual methods), hidden, or implemented (interface members). Whether this is too strict is the subject of
several open questions below.
Copy link
Member

Choose a reason for hiding this comment

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

The interface member part will lead to some interesting situations. Consider the following as a concret example:

// assembly1:
public abstract class C {
  public void Dispose() { }
}

// assembly2:
public class D : C, IDisposable { } 

Now lets say in version 2 the author of assembly1 marks C.Dispose with [BinaryCompatOnly]. Execution of assembly2 will continue to work fine because C.Dispose still satisfies IDisposable.Dispose by CLR rules. The moment the author moves to a new version of assembly1 they will get a compilation error and be forced to re-implement IDisposable.Dispose. That overall may be okay but it seems like a potential pitfall in some cases.

### Use within the same DLL

This proposal states that `BinaryCompatOnly` members are not visible anywhere, not even in the assembly currently being compiled. Is that too strict, or
do `BinaryCompatAttribute` members need to possibly chain to one another?
Copy link
Member

Choose a reason for hiding this comment

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

How does this work with say unit testing? The primary consumer of this technology is likely to be the .NET libraries / runtime team. The [BinaryCompatOnly] attribute will make the method inaccessible to new code but old code will still use it. The method will exist virtually forever for this reason.

How will the libraries team write unit tests for this method that they need to maintain forever?

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe TestUtils.CreateDelegate<Action<DeclaringType, TParam1, ...>>("MethodName") and similar strategies for invoking the method using reflection with low ceremony at the call site?


Should `BinaryCompatOnly` members be able to implement interface members? Or should they be prevented from doing so. This would require that, when a user
wants to turn an implicit interface implementation into `BinaryCompatOnly`, they would additionally have to provide an explicit interface implementation,
likely cloning the same body as the `BinaryCompatOnly` member as the explicit interface implementation would not be able to see the original member anymore.
Copy link
Member

Choose a reason for hiding this comment

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

The interface member part will lead to some interesting situations. Consider the following as a concret example:

// assembly1:
public abstract class C {
  public void Dispose() { }
}

// assembly2:
public class D : C, IDisposable { } 

Now lets say in version 2 the author of assembly1 marks C.Dispose with [BinaryCompatOnly]. Execution of assembly2 will continue to work fine because C.Dispose still satisfies IDisposable.Dispose by CLR rules. The moment the author moves to a new version of assembly1 they will get a compilation error and be forced to re-implement IDisposable.Dispose. That overall may be okay but it seems like a potential pitfall in some cases.

[summary]: #summary

We introduce a new attribute, `System.BinaryCompatOnlyAttribute`, that causes the type or member to which it is applied to be entirely inaccessible from source code.
It will still be emitted as declared, with the accessibility declared, but will be treated as if it does not exist by all other compile-time C# mechanisms.
Copy link
Member

Choose a reason for hiding this comment

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

so can a method not call itself?

what if i have multiple BCOA members, where one calls the other. i feel like that would be totally normal.

> - If the declared accessibility of `M` is `private`, the accessibility domain of `M` is the program text of `T`.

The goal of these additions is to make it so that members marked with `BinaryCompatOnlyAttribute` are completely inaccessible to any location, they will
not participate in member lookup, and cannot affect the rest of the program. Consequentely, this means they cannot implement interface members, they cannot
Copy link
Member

Choose a reason for hiding this comment

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

Consequentely, this means they cannot implement interface members

this also seems wrong. Say i have a BCOA interface member, and then the BCOA impl of htat interface member. That seems no longer supported.

public class BinaryCompatOnlyAttribute : Attribute {}
```

When applied to a type member, that member is treated as inaccessible in every location by the compiler, meaning that it does not contribute to member
Copy link
Member

Choose a reason for hiding this comment

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

Can a BinaryCompatOnly member reference another BinaryCompatOnly member?
e.g,

[BinaryCompatOnly]
public class C1 { }

[BinaryCompatOnly]
public class C2 : C1 { }

Choose a reason for hiding this comment

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

I think that should be possible, just like it is with [Obsolete] elements referring to other [Obsolete] elements

Copy link
Member Author

@333fred 333fred Dec 17, 2023

Choose a reason for hiding this comment

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

As currently proposed, no. This is one of several open questions that I get into.

Copy link

@hamarb123 hamarb123 left a comment

Choose a reason for hiding this comment

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

Just adding my thoughts on how the feature can be improved, and ideas to solve some of the unresolved issues / questions.

### Implementing interface members marked `BinaryCompatOnly`

What do we do when an interface member has been marked as `BinaryCompatOnly`? The type still needs to provide an implementation for that member; it may be
that we must simply say that interface members cannot be marked as `BinaryCompatOnly`.

Choose a reason for hiding this comment

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

For this, I think we could allow it like so: if an interface member definition is marked BinaryCompatOnly, then its implementation must also be explicit, and marked BinaryCompatOnly. All of the parameters & return type would have to exactly match also.


Should `BinaryCompatOnly` members be able to implement interface members? Or should they be prevented from doing so. This would require that, when a user
wants to turn an implicit interface implementation into `BinaryCompatOnly`, they would additionally have to provide an explicit interface implementation,
likely cloning the same body as the `BinaryCompatOnly` member as the explicit interface implementation would not be able to see the original member anymore.

Choose a reason for hiding this comment

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

Perhaps we could have something like BinaryCompatOnlyImplementation(Type), where it specifies an interface which should only be considered to be implemented for binary compatibility purposes. This would allow removing interfaces implementations from usage in source, without breaking their usage in binary.

With this setup, you would disallow explicit interface implementations from being marked BinaryCompatOnly, unless their definition is also this as I proposed in my other comment; for a non-BinaryCompatOnly definition, if the implicit one is marked BinaryCompatOnly, then an explicit one would have to be provided.

### Use within the same DLL

This proposal states that `BinaryCompatOnly` members are not visible anywhere, not even in the assembly currently being compiled. Is that too strict, or
do `BinaryCompatAttribute` members need to possibly chain to one another?

Choose a reason for hiding this comment

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

I think a good solution to this is the following: disallow referencing BinaryCompatOnly members (other than types) completely, their implementation can be extracted to a private / internal member as required which the BinaryCompatOnly will stub to. For unit tests, you would either test the stub, or use reflection.

There is a more interesting question with types though, as you still need to be able to use them in signatures probably (even if just other BinaryCompatOnly signatures); I think the solution to this could be: only use that type if there's no other type which matches (i.e., force all BinaryCompatOnly types to match very last, after every other possibility) (or can be done by specifying full name global:: if needed - probably the recommended way to do it), and emit a warning on usage of a BinaryCompatOnly type (default error severity), which can be suppressed if the usage is intentional. Potential (quite reasonable) adjustment could be that: this only applies for already BinaryCompatOnly members/types, it would be disallowed & ignored otherwise.

The goal of these additions is to make it so that members marked with `BinaryCompatOnlyAttribute` are completely inaccessible to any location, they will
not participate in member lookup, and cannot affect the rest of the program. Consequentely, this means they cannot implement interface members, they cannot
call each other, and they cannot be overridden (virtual methods), hidden, or implemented (interface members). Whether this is too strict is the subject of
several open questions below.

Choose a reason for hiding this comment

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

I mentioned a potential way to solve the usability concerns around this section in my comments below.

I don't understand why hiding would be disallowed here - doesn't shadowing have nothing to do with the original member really? Especially since BinaryCompatOnly effecively pre-emptively shadows a member before anyone can even use it, so I don't see why it would be disallowed here.

public class BinaryCompatOnlyAttribute : Attribute {}
```

When applied to a type member, that member is treated as inaccessible in every location by the compiler, meaning that it does not contribute to member

Choose a reason for hiding this comment

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

I think this shouldn't be possible, you can always extract a binary compat member's impl to another private / internal member as required, and then just keep the binary compat member as a stub to it. Since the member would not be exposed externally, you can just name it whatever works; this allows effectively calling the compat only member, without having to complicate overload rules and similar to figure out which one of the potentially conflicting overloads you wanted (if we allow overloading by ref/virtual in, return types, etc with this feature, which I assume we're allowing, as long as they're binary different).

| AttributeTargets.Struct,
AllowMultiple = false,
Inherited = false)]
public class BinaryCompatOnlyAttribute : Attribute {}

Choose a reason for hiding this comment

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

I presume this should be sealed.

@333fred
Copy link
Member Author

333fred commented Jan 25, 2024

After some workshopping with @jaredpar offline, I'm not longer convinced this is quite the right approach. I'll include it in the alternatives section of a followup, but I'm now thinking more along the lines of simply being able to set the relative priority of an overload during the applicability step of overload resolution. I'll get that proposal out sometime soon.

@333fred 333fred closed this Jan 25, 2024
@CyrusNajmabadi
Copy link
Member

of simply being able to set the relative priority of an overload during the applicability step of overload resolution.

I'm in favor of this, as long as relative priority is specified in either english terms (like "better, betterest, evenbetterest"), or if it's done with floating point numbers (or qubits).

@hamarb123
Copy link

hamarb123 commented Jan 26, 2024

I'm in favor of this, as long as relative priority is specified in either english terms (like "better, betterest, evenbetterest"), or if it's done with floating point numbers (or qubits).

This sounds like a joke to me 😆 (referring to the english terms part)

[Overload("betterest")]
void a();

I'd think we should be able to get away with ints, if not for extension methods, so double or something similar should work probably.

@hamarb123
Copy link

hamarb123 commented Jan 26, 2024

Another question is whether we'd be able to change the return type still if we just allow adjusting overload resolution...

e.g., if I wrote

IEnumerator<int> GetEnumerator() => ...;

some years ago

ideally I would be able to replace it with

[Overload(-1)] IEnumerator<int> GetEnumerator() => ...;
MyStructEnumerator GetEnumerator() => ...; //assume 0 is default, and higher number takes precedence

at some point, but this would obviously be illegal to write currently (without special treatment for overload checking in presence of [Overload]).

And then there's the question of what should I expect the following to call

//should one of these ignore the overload precedences and call the -1 api? if not, it will be hard to call for testing and similar, but if so then it will be hard to ensure the new API is actually called
IEnumerable<int> a = GetEnumerator();
IEnumerable<int> a = (IEnumerable<int>)GetEnumerator();

I am generally in favour of this idea though. Perhaps we could specially assign [Overload(double.NaN)] for "never, ever call this overload".

@333fred
Copy link
Member Author

333fred commented Jan 26, 2024

Another question is whether we'd be able to change the return type still if we just allow adjusting overload resolution

I don't expect so, but I also think the true BinaryCompatOnly option that removed it from the ref assembly has unworkable problems. For example, you'd never be able to test your binary compat only methods, so things like the BCL would simply never use it.

@hamarb123
Copy link

I don't expect so, but I also think the true BinaryCompatOnly option that removed it from the ref assembly has unworkable problems. For example, you'd never be able to test your binary compat only methods, so things like the BCL would simply never use it.

That's a shame. Also, why did BinaryCompatOnly need to remove from the ref assembly? I don't personally remember reading about that - it seems unnecessary to me.

In terms of testing: it's easy enough to move the contents of a BinaryCompatOnly member to a helper method with e.g., internal visibility, and we could have an optional analyser for if any BinaryCompatOnly member's impl wasn't only calling a helper member, which would effectively ensure testability of the binary compat member itself.

@CyrusNajmabadi
Copy link
Member

This sounds like a joke to me

It would be impossible for me to be any more seriouser here (or 'cyrusier' if you prefer).

@333fred
Copy link
Member Author

333fred commented Jan 26, 2024

Also, why did BinaryCompatOnly need to remove from the ref assembly

That was the model it was going for, and what my proposed rules effectively did.

@jaredpar
Copy link
Member

I'm very much convinced adjusting priority is a more promising approach. At the same time I feel bad closing this design without storing it somewhere. The design is fairly complete, captures the big ideas and has a good detailing of the problems that comes with it. Seems like it may be valuable for future reference. Should we just move it to rejected and merge?

@333fred
Copy link
Member Author

333fred commented Jan 26, 2024

I'm going to include it in the alternative section of my followup

333fred added a commit to 333fred/csharplang that referenced this pull request Feb 3, 2024
This is my replacement proposal for what I originally wrote up in dotnet#7707.
@Neme12
Copy link

Neme12 commented Mar 15, 2024

Why was this scrapped :( this is much more versatile than adjusting priority of overloads. This could even allow changing the return type of a property. This could allow outright fixing mistakes, not just trying to hide them or deprioritize them, and it would cut down on all the bloat from multiple overloads added over time.

@Neme12
Copy link

Neme12 commented Mar 15, 2024

This would cut down on complexity, and meanwhile the other approach only increases complexity - and complexity in overload resolution by adding yet more rules - the one place that really doesn't need any more complexity.

@Neme12
Copy link

Neme12 commented Mar 15, 2024

For example, you'd never be able to test your binary compat only methods, so things like the BCL would simply never use it.

You could allow calling those methods via something like UnsafeAccessor.

@HaloFour
Copy link
Contributor

HaloFour commented Mar 15, 2024

This could even allow changing the return type of a property.

Theoretically so could overload priority. Currently the C# compiler is capable of understanding a type that has overloads that only differ by return type, it just can't resolve the ambiguity so it becomes a compiler error. That attribute could be used to note which overload has priority, so the compiler could call the prioritized overload. The lower priority overload could also then be marked as obsolete.

You would then need to couple it with a language proposal to lift the restriction on being able to declare overloads that differ only by return type.

I guess it gets more complicated if the overload differs due to slightly different parameter types, although I think the overload priority would take precedence over the current type coercion rules.

@333fred
Copy link
Member Author

333fred commented Mar 18, 2024

Why was this scrapped

Because I don't believe there's a viable path forward for this proposal.

You could allow calling those methods via something like UnsafeAccessor.

Even if this was a palatable solution (I don't think it would be, as it would require large code changes in swaths of APIs that would want to be removed), calling these methods from tests are only the tip of the iceberg. The issues around OHI are where this really gets impossible.

333fred added a commit that referenced this pull request May 6, 2024
* Add proposal for overload resolution priority

This is my replacement proposal for what I originally wrote up in #7707.

* Fix a typo and rename the file to be correct.

* Adjust what section of the spec is targeted for updates, and support indexers/constructors.

* Add more error locations for the property.

* Add open question on extension member grouping.
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.