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]: typeof string constants #8505

Closed
4 of 5 tasks
jnm2 opened this issue Oct 11, 2024 · 33 comments
Closed
4 of 5 tasks

[Proposal]: typeof string constants #8505

jnm2 opened this issue Oct 11, 2024 · 33 comments
Assignees
Milestone

Comments

@jnm2
Copy link
Contributor

jnm2 commented Oct 11, 2024

  • Proposal added
  • Discussed in LDM
  • Decision in LDM
  • Finalized (rejected)
  • Spec'ed

Summary

For certain kinds of types, typeof(...).FullName is considered a constant value. It is allowed anywhere a string constant is allowed, such as in an attribute argument or a constant interpolated string.

Motivation

The community has requested this feature often. Several duplicates and many variants have been filed, garnering a decent number of upvotes. This would be easy to implement, and it would bring multiple benefits.

First, this change would allow typeof(...).FullName to be used in places where it is the obvious thing to use, but where it is currently disallowed. Today you'll be faced with writing workarounds such as:

[UseThisLogger(
    nameof(Microsoft) + '.' +
    nameof(Microsoft.Extensions) + '.' +
    nameof(Microsoft.Extensions.Logging) + '.' +
    nameof(Microsoft.Extensions.Logging.Logger<>) + "`1")]
...

With this proposal, this would be able to be written naturally as [UseThisLogger(typeof(Logger<>).FullName)]. typeof expressions are already given special treatment by the language as attribute arguments, and this special treatment is extended a little further to make [Attr1(typeof(Xyz).FullName)] work alongside [Attr2(typeof(Xyz))].

Second, this change would bring performance improvements in existing code where typeof(...).FullName is used to build strings. The entire string which includes the type name could become constant, rather than interpolating or concatenating as a runtime operation.

Detailed design

When .FullName is used immediately on a typeof expression, and the referenced type is a supported kind of type, the typeof(...).FullName expression is a string constant. Types are supported when their resulting FullName strings are known at compile time and cannot change at runtime.

Such an expressions will be a constant everywhere, not just in places where a constant is required. The value of the constant is the same as the value the expression would have if evaluated at runtime.

Supported type kinds

Unbound generics are supported. typeof(List<>).FullName would produce the constant string "System.Collections.Generic.List`1".

Nested types are supported. typeof(List<>.Enumerator).FullName would produce the constant string "System.Collections.Generic.List`1+Enumerator".

Primitive type keywords are supported. typeof(nint).FullName would produce the constant string "System.IntPtr". This includes typeof(void).FullName, which produces "System.Void".

Aliases are supported. The constant string resolves the actual type, the same way the expression would evaluate at runtime.

Array types and pointer types are supported, both individually and when composed over each other. typeof(int[]) or typeof(int*) or typeof(int[,][]**) all have constant FullNames. When these composable types are used, their element types must also be supported types.

Unsupported type kinds

Type parameters are not supported. typeof(T) is a type which is not known at compile time.

Bound generics are not supported. typeof(List<int>).FullName contains parts known only at runtime. For example: "System.Collections.Generic.List`1[[System.Int32, System.Private.CoreLib, Version=8.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]]". The compiler may see only a reference assembly with a different name, or an assembly may load with a different version at runtime than at compile time.

Nullable value types and tuple types are special cases of bound generics and are not supported.

Nullability

The FullName property on System.Type is annotated as nullable, but when typeof(Xyz).FullName is a constant, it is never null. When this expression is constant, the compiler's nullability analysis should report it as not-null. This avoids the inevitable need for ! each time this feature is used with an attribute, since it's not likely that the attribute's type name argument is an optional one.

This will also be a benefit in places that use typeof(Xyz).FullName! today.

Possible extensions

typeof(Xyz).Name and typeof(Xyz).Namespace are components of the full name, and could be supported in the same way. Unlike FullName and Name, Namespace could evaluate to a constant null string.

typeof(Xyz<>).Name differs from nameof. Whereas nameof returns the C# language name of the type or alias, typeof(Xyz<>).Name returns the name in metadata, such as "Xyz`1".

typeof(Xyz).Namespace would make it less of a hassle to refer to a full namespace. Currently, the only resort is something like:

nameof(System) + '.' +
nameof(System.Collections) + '.' +
nameof(System.Collections.Generic)

Alternatives

Some of the variants of this request are for a fullnameof or pathof operator. The output would diverge, since nameof today returns the C# name of the type or alias being used while typeof(...).Name returns the metadata name, suitable for reflection. Metadata names use backticks in generic type names and the + separator between containing types and nested types. This same distinction would presumably be maintained between fullnameof and typeof(...).FullName.

fullnameof could reference namespaces without requiring the naming of a type within the namespace.

fullnameof would also be extendable to reference a member, and this is sometimes requested. However, referencing a member along with its containing type name and full namespace seems like specialized use, producing strings which are not usable with any core .NET API. This may be more of a job for an infoof operator, providing a reference to the member and letting it be examined at runtime to produce policy-specific formatting suited to the use case.

Design meetings

https://github.com/dotnet/csharplang/blob/main/meetings/2024/LDM-2024-10-16.md#typeof-string-constants

@CyrusNajmabadi
Copy link
Member

I like it. If we do this, i'd like to do all the stirng ones at the same time. So Name/Namespace/FullName. We'll def see people wanting help with namespaces, so this will be nice to kill two birds with one stone.

@333fred
Copy link
Member

333fred commented Oct 11, 2024

I'm concerned about this. I'm not a huge fan of presuming what the implementation of FullName is; in particular, this line from the docs is concerning:

or null if the current instance represents a generic type parameter, an array type, pointer type, or byref type based on a type parameter, or a generic type that is not a generic type definition but contains unresolved type parameters.

@CyrusNajmabadi
Copy link
Member

Note: all those cases where the runtime guarantees a constant, and we have exactly that information statically, you would get hte same string. only7 in the cases where the name is not a constant (it actually may vary at runtime) would you be disallowed from doing this.

so, if you say typeof(List<>).FullName you're all good. But if you did do typeof(List<int>).FullName you'd be blocked. As, well, that really isn't a constant and has runtime dependent behavior.

@jnm2
Copy link
Contributor Author

jnm2 commented Oct 11, 2024

I also want to point out that these are the same strings the compiler already emits into the binary when the same typeof expression (sans FullName) is used as a System.Type attribute argument.

typeof has limitations that don't apply to general System.Type instances, and is not capable of constructing some of the System.Types that the docs are describing there, and besides that, there are further limitations in this proposal. So we don't have to worry about the full range of what the runtime does, only what it does in a highly restricted subset of System.Types.

This may require some work on the runtime side to document guarantees that the runtime is willing to make. I'd be happy to help with that, like I did with the Module Initializers feature.

@333fred
Copy link
Member

333fred commented Oct 11, 2024

I also want to point out that these are the same strings the compiler already emits into the binary when the same typeof expression (sans FullName) is used as a System.Type attribute argument.

I do not see anywhere in the ECMA 335 standard that specifies that reflection's FullName property is formatted the same way as the full name in a SerString-encoded type argument. There's at least one immediate difference; the name in metadata is assembly-qualified (except in 2 specific scenarios), while typeof(Type).FullName is specifically not assembly-qualified. I do not feel confident in saying that everything else lines up precisely.

@Flithor
Copy link

Flithor commented Oct 12, 2024

@333fred What you might be worried about is that there is no longer a check the strongly named assembly here.
Assuming that the name string is expected to be obtained through getting the type in a specific strongly named assembly, then if it becomes a constant string, it is no longer to check for a strongly strongly named here at all. It even not to load the assembly here, which may indeed lead to unexpected and unsafe behavior.

Another possible bad thing is: suppose .NET Team decides to change the format of Type.FullName - although this is probably very unlikely - if it changes, the constant strings will incompatible.

@timcassell
Copy link

I think a more robust solution would be for the runtime to allow "runtime constants" to be used where currently only compile-time constants are allowed. Related dotnet/runtime#108416. Though, that would preclude older runtimes.

@333fred
Copy link
Member

333fred commented Oct 12, 2024

@333fred What you might be worried about is that there is no longer a check the strongly named assembly here.

I'm worried about precisely what I said: I don't feel comfortable presuming how the implementation of FullName formats things. No more, no less.

@alrz
Copy link
Member

alrz commented Oct 12, 2024

For this to not depend too much on the runtime format, I think this could be treated as a constant only in constant context (where it's not allowed today), anywhere else typeof(T).FullName would be evaluated at runtime (to force constant evaluation, a const local could be used). I think that's necessary if you consider obfuscation, today typeof(T).FullName will return a different string after types are renamed after compilation, if this is applied everywhere there needs to be a way to opt out of constant evaluation.

However, I think nullability can still apply anywhere since these are now well-known members.

@iam3yal
Copy link
Contributor

iam3yal commented Oct 12, 2024

@jnm2 It might be nicer if we could have more control so for example imagine we could quote part of what exists inside nameof so for example: nameof(`Microsoft.Extensions.Logging.Logger<>`) note the backtick ` inside nameof that results Microsoft.Extensions.Logging.Logger`1 now say you want only Microsoft.Extensions.Logging.Logger then you can do something like this nameof(`Microsoft.Extensions.Logging.Logger`<>) the only downside is that you actually have to pass the fullname whereas with typeof you don't and I actually think that not depending on the behaviour of typeof is a good thing but the result might not be exactly what people want but it can come close enough in most cases.

@HaloFour
Copy link
Contributor

If this were to be implemented, I would expect typeof(...).FullName to return the exact same value whether it's considered a constant by C# or if it's evaluated at runtime. That includes the formatting of the string. If we want a C# flavor of that constant, I think that would necessitate a language feature specific to that purpose.

I do think @alrz has a good point about cases where the type name at compile time might not match the type name at runtime. You could put it on the obfuscator to identify and replace string constants that contain type names, but that might be a bridge too far?

@jnm2
Copy link
Contributor Author

jnm2 commented Oct 14, 2024

@alrz That's a good point about post-compilation rewriters. Even as a constant, in some cases, it's not always clear if "A.B" is intended as a type name reference that needs to stay in sync with a type rename, or something that was accidentally scooped up. It might have been a type without a namespace and now you're hitting a lot of string constants that might be totally unrelated.

So, two things. First, this is already an issue if you're moving or mangling types. Trimming has this same issue. Qualified type names can come from many places, including config files, and post-compilation renaming already breaks scenarios like that. This would just be another example among many. Existing mitigations should work, such as [DontObfuscateThis]. A smart obfuscator seeing WriteLine(typeof(Xyz).FullName); may already avoid mangling that name.

Second, the way to perfectly avoid this issue is for the post-compilation step to have access to the source code and Roslyn semantics for the strings that it's seeing. That way post-compilation can tell the difference between const string name = "Program" and const string name = typeof(Program).FullName. But if it's doing so, then it can also do so in non-constant locations, such as var message = "${typeof(Program).FullName} starting";. In this way, it would know equally clearly whether or not to fix up the ldstr for the string "Program starting". Thus, I don't yet see a case for restricting this to only apply in constant locations. Does this create extra work for people who maintain obfuscators? Possibly. Does this possibility fill my heart with sadness? No.

@jnm2
Copy link
Contributor Author

jnm2 commented Oct 14, 2024

@iam3yal I'd be interested in hearing concrete use cases. Anything we build will have to contend with the split between metadata names, which use backticks for generics and + separators for nested types, versus C#-like syntax, which uses angle brackets for generics and . separators for nested types.

@iam3yal
Copy link
Contributor

iam3yal commented Oct 14, 2024

@jnm2 I might misunderstanding you but the use cases are exactly the same use cases as in the OP. My point was to enhance nameof as opposed to have a new operator such as fullnameof or have pseudo constexpr like methods such as typeof(T).Fullname and variations.

The backticks in my suggestions can be used only and only in the context of nameof, they are used to control what part of the result you want to get so when the type is fully quoted like this nameof(`A.B.C<T>`) then you can expect to get the exact same results you noted in your OP or whatever output that was agreed upon.

Just to hopefully make things crystal clear, It doesn't have to be backticks so they are really not that important to the point I'm trying to make.

It's just a different take that aims to achieve the same thing. :)

@CyrusNajmabadi
Copy link
Member

I expect we would use backtics for something much larger in the language.

@HaloFour
Copy link
Contributor

Instead of treating the existing expression as some kind of constexpr, which may or may not have the desired effects, how about considering a small number of special methods that would be unimplemented and which the compiler would recognize and replace with the constant values at compile-time? That would give the C# compiler full control over what the methods are expected to return.

// replaced at compile-time
var fullName = Constants.FullNameOf(typeof(Foo));
var fullClrName = Constants.FullClrNameOf(typeof(Foo));

// compiler errors
var x = Constants.FullNameOf(typeof(T)); // generics
var y = Constants.FullNameOf(t); // Type variable

@jnm2
Copy link
Contributor Author

jnm2 commented Oct 14, 2024

I would think sooner of keyword-first syntax in the line of nameof, but that could be an option.

@iam3yal
Copy link
Contributor

iam3yal commented Oct 14, 2024

@CyrusNajmabadi I understand. Although it can be anything else like double quotes iirc it's invalid inside nameof but then again I don't really care about how it's quoted but the idea of using the same operator but having more control on the results.

@TahirAhmadov
Copy link

I think this is yet another use case for deterministic/constant functions.

HaloFour's idea makes sense to me. Instead of tying this to the attribute argument type encoding (which we apparently cannot do), we should have the language and the actual Type.FullName property both call the future Constants.FullnameOf() - the latter only using it when the type satisfies the same criteria as the language - and that future Constants.FullnameOf() would be a constant/deterministic function.

Yes, this means this feature will not be available for a while, but I think it's important to get it right and the need for this specific use case is not that dire to justify making it an exceptional case.

@jaredpar
Copy link
Member

I would think sooner of keyword-first syntax in the line of nameof, but that could be an option.

Keywords are expensive. It's new syntax and that has a substantial cost associated with it. At the minimum, it leads to the "do we take a breaking change or var appraoch" with the syntax.

how about considering a small number of special methods that would be unimplemented and which the compiler would recognize and replace with the constant values at compile-time?

At that point it would be more of a compiler feature than a language one. Essentially a set of intrinsics the compiler could define in the form of methods. There would be a significant cost to design this out and model them in the compiler (there is no concept of constant methods today and that would take work to introduce). After that though it should be more iterative to add new ones.

Where else would we use this? If it were just for full name it's probably not worth the cost. But if we had a host of problems we could solve this way it may be an idea worth exploring and see where it goes.

@jnm2 jnm2 added this to the Likely Never milestone Oct 16, 2024
@jnm2
Copy link
Contributor Author

jnm2 commented Oct 16, 2024

LDM rejected as likely never, with sympathy for the scenarios but worry about dark corners. There is interest in a more ambitious solution. One such example could be a form of constexpr with interception, where source generator plugins can decide how to provide constants.

There's also a workaround today if you're willing to get up to some ceremony. This comes in the form of source generators generating constants for types that you ask for. So if you find yourself in a corner needing a constant and not wanting to hardcode a string, you could write a source generator.

@jnm2 jnm2 closed this as not planned Won't fix, can't repro, duplicate, stale Oct 16, 2024
@TahirAhmadov
Copy link

One potential workaround is a code fixer:

  • If typeof(Foo).FullName is encountered and Foo satisfies the criteria, then we offer to replace that with "Namespace.Foo" /*typeof(Foo).FullName*/.
  • If the above "Namespace.Foo" /*typeof(Foo).FullName*/ is encountered and namespace has changed, offer to fix the namespace in the string literal.

I've never written a code fixer, so I can't guarantee it's a workable approach - just throwing an idea out there in case somebody finds it useful.

@jnm2
Copy link
Contributor Author

jnm2 commented Oct 16, 2024

@TahirAhmadov That's a cool idea. However if you perform a rename or Find All References on Foo, the comment won't be included. "Remove usings" also won't respect it.

@TahirAhmadov
Copy link

TahirAhmadov commented Oct 16, 2024

Hahaha, there may be a solution for those issues:

string s = "Namespace.Foo" + GetFullName<Foo>();

// defined somewhere globally
string GetFullName<T>() => "";

Or something along those lines, hopefully in a way that it gets optimized away.

@TahirAhmadov
Copy link

TahirAhmadov commented Oct 16, 2024

string s = GetFullName<Foo>("Namespace.Foo");

// defined somewhere globally
string GetFullName<T>(string v)
{
#if DEBUG
  if(typeof(T).IsGeneric) throw new Exception("Invalid type"); // list all the criteria here
  if(v != typeof(T).FullName) throw new Exception("Literal incorrect, use code fixer to update");
#endif
  return v;
}

@jnm2
Copy link
Contributor Author

jnm2 commented Oct 16, 2024

@TahirAhmadov Places where a method can be called, there would be no reason not to use typeof(Foo).FullName instead.

@TahirAhmadov
Copy link

You're right, of course. For some reason I approached it from the perspective of performance improvement and totally forgot about constant context. Oh well.

I think constant functions can really solve this and many other scenarios nicely. I hope it's the next big thing that the language gets.

@jnm2
Copy link
Contributor Author

jnm2 commented Oct 16, 2024

(Now if we did your first idea, it would have to be called Constant Comment <T>)

@hrumhurum
Copy link

hrumhurum commented Dec 9, 2024

Adding constexrp keyword to the language would solve the original problem, as well as to possibly open some perspectives for meta-programming in the future:

const string s = constexpr(typeof(int).FullName);

Just 2 cents.

@iam3yal
Copy link
Contributor

iam3yal commented Dec 25, 2024

@hrumhurum Adding constexpr to the language wouldn't do anything, as per C++ in your example FullName needs to be written as a constexpr property so it's not just as simple as adding a keyword and don't know about meta-programming, not sure how it's related.

@jnm2
Copy link
Contributor Author

jnm2 commented Dec 26, 2024

@iam3yal See #8505 (comment) for what this is referring to. I'm interested in constexpr which is driven by interception via constant instead of interception via method.

@iam3yal
Copy link
Contributor

iam3yal commented Dec 26, 2024

@jnm2 Can you briefly clarify what you expect to happen from something like this?

const string s = constexpr(typeof(int).FullName);

I mean, do you expect the compiler to stop on constexpr in the solution, have some sort of a .NET server where you can query the runtime for whatever data and then embed it as part of the compilation?

@jnm2
Copy link
Contributor Author

jnm2 commented Dec 26, 2024

It would be exactly like what interception is today. Rather than source-generating a method with an interception attribute, you'd source-generate a constant with a similar interception attribute. The compiler would require something to intercept the constexpr expression or it would error.

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