C
C#14mo ago
__dil__

❔ Generics and nullable types

This snippet below results in an error
class Foo<T>
{
T? _foo;

Foo()
{
_foo = null; // Error here
}
}
class Foo<T>
{
T? _foo;

Foo()
{
_foo = null; // Error here
}
}
Cannot convert null to type parameter 'T' because it could be a value type. Consider using 'default(T)' instead.
I don't really understand the error. I thought the type of _foo was T?, not T.
37 Replies
Thinker
Thinker14mo ago
ah yes, the classic You're familiar with Rust, right?
__dil__
__dil__OP14mo ago
indeed In fact I'm trying to mimic a Rust pattern, which I imagine is why I'm hitting problems. To me, the C# code is equivalent (more or less) to
struct Foo<T>(Option<T>);
struct Foo<T>(Option<T>);
but clearly that's not the case since in the Rust case there are no restrictions on what T can be
Thinker
Thinker14mo ago
T? in C# is very different from Option<T> in Rust. In particular, nullable types in C# are really two different things which use the same T? syntax: nullable reference types, and nullable value types. Nullable value types use the Nullable<T> struct, which is a lot like Option<T>, but it has a where T : struct constraint which only permits value types to use Nullable<T>. Nullable reference types are nothing more than a compiler annotation (for backcompat reasons), since reference types in C# can always be null, so T? (where T : class) is the same as T but with an annotation saying that it may be null. The consequence of this is that T? for an unconstrainted T is not Nullable<T> but rather just the annotation, and it doesn't do anything for value types.
MODiX
MODiX14mo ago
Thinker
REPL Result: Success
class C
{
public static T? GetNullableDefault<T>() => default;
}

new {
ReferenceType = C.GetNullableDefault<string>(),
ValueType = C.GetNullableDefault<int>(),
}
class C
{
public static T? GetNullableDefault<T>() => default;
}

new {
ReferenceType = C.GetNullableDefault<string>(),
ValueType = C.GetNullableDefault<int>(),
}
Result: <>f__AnonymousType0#1<string, int>
{
"referenceType": null,
"valueType": 0
}
{
"referenceType": null,
"valueType": 0
}
Compile: 567.011ms | Execution: 77.621ms | React with ❌ to remove this embed.
Thinker
Thinker14mo ago
However if you constraint it to struct then it'll actually be null (or None if you wanna look at it like Option<T>)
MODiX
MODiX14mo ago
Thinker
REPL Result: Success
class C
{
public static T? GetNullableDefault<T>() where T : struct => default;
}

new {
ValueType = C.GetNullableDefault<int>(),
}
class C
{
public static T? GetNullableDefault<T>() where T : struct => default;
}

new {
ValueType = C.GetNullableDefault<int>(),
}
Result: <>f__AnonymousType0#1<Nullable<int>>
{
"valueType": null
}
{
"valueType": null
}
Compile: 558.263ms | Execution: 77.528ms | React with ❌ to remove this embed.
Thinker
Thinker14mo ago
Rust is largely unique in that it got to be designed with option types at its core, but C# wasn't, so what we have now is an absolutely inconsistent mess
__dil__
__dil__OP14mo ago
Just to make sure I understand correctly: this restriction is artificial, right? Like in theory the code could be valid for both nullable value type and reference types, but the C# compiler just currently doesn't allow one to express this?
Thinker
Thinker14mo ago
Well, it's impossible to express a value type as null without Nullable<T>, and it's always possible for reference types to be null
__dil__
__dil__OP14mo ago
In my case I don't care what the actual type, as long as I can assign null to it. I thought there might be a way to express this constraint
reflectronic
reflectronic14mo ago
it is not possible when the generic type is unconstrained, T? really means "maybe default," not "maybe null"
__dil__
__dil__OP14mo ago
right
Thinker
Thinker14mo ago
The common 'solution' for this is to have two different variants of whatever thing you're writing, one which is constrainted to struct and one which is not, but for a class like this then it's not really practical.
__dil__
__dil__OP14mo ago
The actual code where I'm hitting this is a generic Peekable<T> adapter that allows one to add peeking behavior to any IEnumerable. The simple way to implement this is to cache the peeked value. The peeked value can be "None" (or null), so yeah. Since I can't constrain the generic T to be "something that can be assigned null" and that there is no Option type I'm not sure how to proceed. that could be a potential solution I imagine
Thinker
Thinker14mo ago
Use a bool TryPeek(out T value) method And besides, T could naturally be int? or whatever, so null could be a possible value even if the peek succeeded
__dil__
__dil__OP14mo ago
oof :/
Thinker
Thinker14mo ago
yeah int?? isn't possible Try methods are the most common alternative to null/option-returning methods in C#
__dil__
__dil__OP14mo ago
Rust has really spoiled me in a way, I find myself hitting walls and limitations left and right. I know I just have to readjust how I design my programs, but it's not easy 😅 thanks, that's good to know
Thinker
Thinker14mo ago
Yeah Rust is a ridiculously well-designed language
qqdev
qqdev14mo ago
Rust is love ❤️
Thinker
Thinker14mo ago
The fact C# was more or less designed as a Java derivative has really hurt what it wants to be in modern day Nullable reference types are a hack, and a precarious one at that Because it's always possible to use null! to just make the compiler shut up about nullability differences
__dil__
__dil__OP14mo ago
Yeah from the little I've explored so far it really seems like a mix of well-designed modern stuff and also very outdated ideas. I rather like C#, but it's not easy for me to make the switch lol. I guess it'll get better as I learn more. One thing for sure is that I'm grateful for all the help I'm getting. Makes things so much easier and pleasant ❤️
Thinker
Thinker14mo ago
We still have System.Collections.ArrayList from C# 1 before generics were a thing And MS refuses to remove it because there's probably some company somewhere which has some critical code which relies on it So uh, we have tons of legacy catsweat
__dil__
__dil__OP14mo ago
Yeah that's understandable, C# is a big language (technically speaking and also in the industry), and it has a ton of baggage. I try to focus on the modern core but yeah, I'm still having a hard time 🙂 I'll try the Try pattern now. Thanks.
Thinker
Thinker14mo ago
yw catsip tried Rust pretty recently and I can absolutely agree it's hella nice, and coming back to C# afterwards feels the language is missing half of itself.
__dil__
__dil__OP14mo ago
Rust was my first programming language and the only one where I'm at a professional level with. I've learned other languages since then of course, but everything else is so different (or rather, Rust is...) so it's pretty overwhelming.
reflectronic
reflectronic14mo ago
regardless it is really important to note that int? is very different from string?. just, in general int? is an actual option type. it's a syntax shorthand for https://learn.microsoft.com/en-us/dotnet/api/system.nullable-1?view=net-7.0. changing int to int? makes a very big difference because they are not actually the same thing
__dil__
__dil__OP14mo ago
noted!
reflectronic
reflectronic14mo ago
string? is identical to string in every way, except for how it impacts the nullability static analysis done by the C# compiler. the annotations have no impact on the runtime representation or behavior. it is simply input to a flow analysis, which determines the points at which a variable may be null (and whether that is OK for the operations done in the code). the analysis is not perfect
__dil__
__dil__OP14mo ago
right. On the other hand, I imagine that for value types this affects the memory footprint, like how int? could actually be 8 bytes instead of 4 so it contains a flag for null. or something along those lines
reflectronic
reflectronic14mo ago
in other words:
string? x = Get();
// x may be null here
if (x != null)
{
// x must not be null here, so, no warning is raised
_ = x.IndexOf(...);
}
else
{
// x may be null here, so, a warning is raised
x.ToCharArray();
}
string? x = Get();
// x may be null here
if (x != null)
{
// x must not be null here, so, no warning is raised
_ = x.IndexOf(...);
}
else
{
// x may be null here, so, a warning is raised
x.ToCharArray();
}
Thinker
Thinker14mo ago
it's one bool + the actual value
reflectronic
reflectronic14mo ago
whereas, for int?, there is an explicit unwrapping gesture, because it is really a wrapper with a flag
__dil__
__dil__OP14mo ago
right, but because of alignment requirements I believe this would still be 8 bytes unless then compiler does some magic with packed representations and whatnot
reflectronic
reflectronic14mo ago
right, int? is eight bytes
__dil__
__dil__OP14mo ago
Thanks for the explanations @🌈 Thinker 🌈 @reflectronic, now I understand much better and I'll be able to avoid footguns in the future 😄
Accord
Accord14mo ago
Was this issue resolved? If so, run /close - otherwise I will mark this as stale and this post will be archived until there is new activity.
Want results from more Discord servers?
Add your server