-2

Considering example below, one observe that for reference type argument is successfully understood as nullable parameter. For value type conversion to T?/Nullable<T> fails.

T Method<T> ( T? t )
{
  // Error  CS1503  Argument 1: cannot convert from '<null>' to 'float'
  float f1 = Method<float> (null);
  // ⇑⇑ float <float>(float) ⇑⇑

  // Error  CS1503  Argument 1: cannot convert from 'float?' to 'float'
  float f2 = Method<float> ((float?) null);
  // ⇑⇑ float <float>(float) ⇑⇑

  object? o1 = Method<object> (null);
  // ⇑⇑ object <object>(object?) ⇑⇑
  object o2  = Method<object> ((object?) null);
  // ⇑⇑ object <object>(object?) ⇑⇑

  return t;
}

Comments show resolved method signature. For value type ? notion is completely ignored. That prevents null usage on input.

It is not so obvious what prevents compiler in understanding when T? should be understood as notion and when as declaration.

3 Answers 3

7

A reference type parameter compiles to machine code that passes the value of a pointer to the passed variable to the subroutine, and all accesses in the subroutine implicitly dereference this pointer.

For a value type parameter, the computer has to pass the value itself (in a register, on the stack or a similar type of storage), but an unset nullable type has no representation in bits. Even "all-0" is most often wrong; a numeric value of 0 is very different from one that isn't defined. Therefore, instead of including super-special logic just for this not very useful use case, most compilers simply forbid it.

1
  • I edited my question since I asked something slightly different. Of your answer only the part Therefore, instead of including super-special logic just for this not very useful use case, most compilers simply forbid it. is relevant for me. Commented Jul 14, 2021 at 19:26
0

Signature is definitively unique so it cannot represent 2 overloads at once.

Signature

T Method<T> ( T? t ) { … }

cannot be understand

T Method<T> ( T? t ) { … } // where T is any reference type

and

T Method<T> ( Nullable<T> t ) { … }

at once.


So it is chosen to go with nullable-annotated reference type.

#nullable enable
void Method<T> ( T? t )
{
  Method<int> (9);      
}
#nullable disable

IL

.method /* 0600002A */ private hidebysig 
    instance void Method<T> (
        !!T t
    ) cil managed 
{
    .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
        01 00 02 00 00
    )
    …  

NullableContextAttribute serves compiler null state analysis of reference types.


Thoughts

  1. If T? declared Nullable<T> and T declared Nullable<U>, then T would be of type Nullable<Nullable<U>. That is prohibited.
  2. If T? declared Nullable<T> and T was not Nullable<U>, then it would break generic method declaration since Nullable<T> and T are different types.

struct constraint squashes all problems making moderate, flat solution to problem.

2
  • NOTE: If you add a where T:Object then T is always a reference object so the T? can be supported. That prevents the struct from using any instance where T itself is a struct. Commented Feb 21, 2022 at 21:58
  • Usually class is used for this purpose. Commented Feb 21, 2022 at 22:17
-1

In Swift, when T is a type, “nullable T” is a new type that needs one bit more for storage before optimisation. As an optimisation, for types where “all bits zero” is not valid, “all bits zero” is used to represent nil, otherwise it must be some extra bit. Lucky enough, that includes all pointers so nullable pointers don’t take more space.

Passing nullable values in Swift is no problem. A nullable 64 bit Int requires 65 bits, so will in practice use 128 bits. So it’s just a 128 bit value being passed. Reference values basically pass a reference (a pointer) by value.

And for example nullable Int is indeed very useful - for example a scanner parsing an int returns a nullable int, with nil on error.

9
  • "where “all bits zero” is not valid, “all bits zero” is used to represent nil" Not quite. Any uninhabited bit pattern (rust calls this a "niche") can be used. For example, nil optional Bool is modelled by 0b10. You can see this with unsafeBitCast(nil as Bool?, to: UInt8.self) Commented Jul 3, 2021 at 17:15
  • I didn’t know the exact rules - the one that allows nullable pointers without extra data is the most important one. Since nullable things are just a clever enum with syntactic sugar, I wonder what other tricks the compiler uses for other enums. Say an enum with variants “nil”, “unknown”, “not read yet” and “known” with an actual value. Commented Jul 3, 2021 at 17:59
  • I haven't looked at the implementation myself, I would try to avoid baking in any special behaviour towards references types. I would aim to express the set of occupied values (all possible pointers) and all unoccopied values (invalid pointers), and feed that into a more generic niche-finding/exploiting mechanism. Commented Jul 3, 2021 at 18:19
  • I was playing around with this, and it turns out that you can fit a lot of enum cases into an enum that stores a non-nullable reference in an associated value. Try enum E<T: AnyObject> { case reference(T), a, b, c, ... } ; print(MemoryLayout<E<C>>.size). I could add at least 524,288 such cases, while still keeping E<C> to 8 bytes. Commented Jul 3, 2021 at 18:23
  • 1
    Question was specifically about rules that applies to C# language. So providing general or specific answer is just right. Eccentric details of Swift does not fit. Commented Jul 3, 2021 at 18:38

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.