Back to Dotnet

Feature attribute semantics

docs/design/tools/illink/feature-attribute-semantics.md

11.0.10012.9 KB
Original Source

Feature attribute semantics

This specification defines the semantics of "feature attributes", in terms of a hypothetical RequiresFeature attribute type. The rules described here are designed to be applicable to any attribute that describes a feature or capability of the code or the platform.

Terminology

There's a history in .NET of using the terms "features" and "capabilities" to describe closely related concepts.

In runtime we often refer to "feature switches", which are toggles that can tell illink and NativeAot to treat specific predefined properties as constants. There are also "runtime features" such as support for new language features like byref fields.

We also often refer to "platform capabilities" such as the ability to run dynamic code, or the ability to create new threads. Sometimes the deployment model may restrict the set of available APIs; in that case we also may refer to "capabilities" (for example the ability to access files on disk, which is not available in single-file apps).

Capabilities will often have associated attributes and/or feature switches. For example, RequiresDynamicCodeAttribute is used to annotate code which requires support for running dynamic code, and RuntimeFeature.IsDynamicCodeSupported can be used to check for this support and guard access to annotated code. We also have many feature switches for features which depend on the availablility of so-called unreferenced code (that is, code which may be removed by trimming), which is attributed with RequiresUnreferencedCodeAttribute.

Feature switch settings may also be determined by explicit configuration (in addition to the choice of target platform or deployment model). For example, the System.Globalization.Invariant feature switch can be used to disable support for culture-specific globalization behavior.

This design takes the view that "features" and "capabilities" are essentially the same concept. We use the terminology "features" for these attribute semantics, because it seems to have slightly more general usage. "Features" as used today often includes runtime features which don't have feature switches or attributes, whereas "capabilities" is most often used to refer to capabilities of the platform or deployment model and do usually come with feature switches.

The intention is for this specification to be generally applicable to any feature which would benefit from an attribute used to annotate code that should only be run when a given feature is available. The decision whether to introduce such a feature attribute is of course determined case by case by the feature owners.

Motivation

Existing attributes like RequiresUnreferencedCodeAttribute, RequiresDynamicCodeAttribute, and RequiresAssemblyFilesAttribute have behavior close to what is described below. The behavior differs slightly between illink and NativeAot in the details, so this is an attempt to specify the semantics as clearly as possible, so that both tools can converge to match this.

The ILLink Roslyn analyzer also produces warnings for these attributes, but doesn't have insight into the compilation strategy used for compiler-generated code. These rules are designed so that the warnings produced by a Roslyn analyzer are matched by the IL analysis, but IL analysis may include additional warnings (specifically for reflection access to compiler-generated code).

There is also the possibility that we will create an attribute-based model which allows users to define their own feature attributes; see this draft for example: https://github.com/dotnet/designs/pull/261. The semantics outlined here could be extended to those attributes if we determine that they are appropriate there.

We would also like to share as much code as possible for this logic between the ILLink, NativeAot, the corresponding analyzers, and possibly future analyzers.

Goals

  • Define the semantics of feature attributes
  • Define the access patterns which are allowed and disallowed by these semantics

Non-goals

  • Specify the warning codes or wording of the specific warnings for disallowed access
  • Define a model for defining new feature attributes
  • Define an attribute-based model for feature switches
  • Specify the relationship between feature switches and feature attributes
  • Define the interactions between RequiresUnreferencedCodeAttribute and DynamicallyAccessedMembersAttribute

RequiresFeatureAttribute

RequiresFeatureAttribute may be used on methods, constructors, or classes only.

The use of this attribute establishes a feature requirement for the attributed type or member, which restricts access to the attributed type or member (and in some cases to other related IL) in certain ways. It also establishes a feature available scope (which includes the attributed member but may also include other related IL) wherein access to members with a feature requirement is allowed.

Access to members with a feature requirement is always allowed from a feature available scope, and never produces feature warnings. The restrictions created by feature requirement only limit access from scopes outside of feature available, where certain access patterns produce warnings.

Feature available scope

The following constructs with a feature requirement are also in a feature available scope:

  • Methods
  • Constructors (including static constructors)

When a class or struct has a feature requirement, the following members are in a feature available scope:

  • Methods
  • Constructors (including static constructors)
  • Fields
  • Properties
  • Events

Note that the feature available scope for a type does not extend to nested types or to members of base classes or interfaces implemented by the type.

Feature requirement

Methods

When RequiresFeature is used on a method or constructor (except static constructors), this declares a feature requirement for the method.

RequiresFeature on a static constructor is not supported. Note however that static constructors may have a feature requirement due to the declaring type having a feature requirement.

Classes

When RequiresFeature is used on a class, this declares a feature requirement for the class.

When a class has a feature requirement, this creates a feature requirement for the following members of the class:

  • static methods
  • all constructors (static and instance)
  • static fields
  • static properties
  • static events

Note that this does not create a feature requirement for nested types or for members of base classes or interfaces implemented by the type. Note also that this may create a feature requirement for fields, properties, and events, which cannot have RequiresFeature used on them directly.

Structs

When a struct has a feature requirement, this creates a feature requirement for the following members of the struct:

  • all methods
  • all constructors (static and instance)
  • all fields
  • all properties
  • all events

Note that structs may have feature requirement due to compiler-generated code, even though they can not have RequiresFeature. Note also that this does not create a feature requirement for members of interfaces implemented by the type.

State machine types

When an iterator or async method is in a feature available scope, the compiler-generated state machine class or type has a feature requirement.

Nested functions

When a method is in a feature available scope, lambdas and local functions declared in the method have a feature requirement.

When a lambda or local function is in a feature available scope, lambdas and local functions declared in it have a feature requirement.

When a lambda or local function is declared in a method or nested function which is in a feature available scope, then the following compiler-generated type or members have a feature requirement:

  • The generated closure environment type, if it is unique to the lambda or local function, OR

  • The generated method for the lambda or local function, if the compiler does not generate a type for the closure environment, OR

  • The generated method and delegate cache field for the lambda or local function, if these are generated into a static closure environment type.

Note that IL analysis tools currently deviate from this specification because the IL does not always contain enough information to reconstruct the original nesting of lambdas and local functions. (For ILLink, lambdas and local functions inherit feature requirement from the enclosing user method, not from an enclosing lambda or local function if one is present.)

Validation behavior

RequiresFeature attribute

RequiresFeature on a static constructor is disallowed.

RequiresFeature on a method that already has a feature requirement due to another attribute is allowed.

RequiresFeature on a method that is in a feature available scope is allowed. This establishes a feature requirement for the method even if there was not one previously. (Note: this could be made stricter by warning about redundant RequiresFeature on methods that are already in a feature available scope.)

Virtual methods

  • Overriding a method that has a feature requirement with a method outside of a feature available scope is disallowed.
  • Overriding a method outside of a feature available scope with a method that has a feature requirement is disallowed.

Member access

Access to a feature requirement method, constructor, field, property, or event outside of a feature available scope is disallowed.

Feature checks

Some feature attributes also come with corresponding feature checks that can be evaluated as constant at the time of trimming, with the guarded code removed when a feature is disabled. This effectivtely places the guarded code in a feature available scope for the purposes of this analysis. However, the definition of such feature checks is left unspecified for now.

Trimming

These semantics have been designed with trimming in mind. When a feature is disabled (by user configuration, or based on limitations of the target platform), trimming an app that will remove most or all of the feature-related code. Specifically, when a feature is disabled and an app has no trim warnings (including suppressed warnings):

  • Methods, fields, properties, and events which have a feature requirement for the disabled feature may be removed.

  • Methods which are in a feature available scope for the disabled feature, but aren't entirely removed, may have the method body replaced with a throwing instruction sequence.

Thie latter can happen for methods in a type with feature requirement (but that do not themselves have feature requirement) that are referenced outside of a feature available scope. The reference to such a method may remain even though the type is never constructed. The callsite would produce a NullReferenceException and the method body is unreachable.

Alternatives

One simplification would be to unify the concepts of feature requirement with feature available, and treat both as similar to preprocessor symbols, where any reference to a guarded type or member from an unguarded context is disallowed.

The advantage of the specified model is that it allows some references without warning, giving some extra flexibility and making it easier to migrate existing code. The downside is that it might lead to preserving more code, whereas a simplified model could guarantee that all code related to a disabled feature is removed.

Here is an example of a pattern which does not warn in the current model, but would warn with a simplified model. Assume that the code under SomeFeatureIsSupported is removed when the feature is unavailable.

csharp
class FeatureConsumer {
    static void Run() {
        SomeFeatureProvider? some;
        if (Features.SomeFeatureIsSupported)
            some = new SomeFeatureProvider();
        OtherFeatureProvider other = new();
        Helper(some, other);
    }

    static void Helper(SomeFeatureProvider? some, OtherFeatureProvider other) {
        some?.Use(); // This callsite would warn with the simplified model.
        other.Use();
    }
}

[RequiresSomeFeature]
class SomeFeatureProvider {
    public void Use() {}
}

class OtherFeatureProvider {
    public void Use() {}
}

Note that the SomeFeatureProvider type and its Use method are kept, but the Use method will be rewritten to throw.

The simplified model would encourage the above to be rewritten as follows, resulting in the entire type SomeFeatureProvider being removed:

csharp

class FeatureConsumer {
    static void Run() {
        if (Features.SomeFeatureIsSupported) {
            var some = new SomeFeatureProvider();
            some.Use();
        }
        OtherFeatureProvider other = new();
        other.Use();
    }
}

Perhaps we could introduce the simplified model as an optional strict mode for people who are interested in rewriting their code for maximal size savings.