0

I wrote a class called Enumerable that implements IEnumerable<T> and IEnumerator<T> using a lambda function as an alternative to using a generator. The function should return null to indicate the end of the sequence. It allows me to write code such as the following:

    static class Program
    {
        static IEnumerable<int> GetPrimes()
        {
            var i = 0;
            var primes = new int[] { 2, 3, 5, 7, 11, 13 };
            return new Enumerable<int>(() => i < primes.Length ? primes[i++] : null);
        }

        static void Main(string[] args)
        {
            foreach (var prime in GetPrimes())
            {
                Console.WriteLine(prime);
            }
        }
    }

Here is the implementation of this class (with some of the code snipped out for brevity and clarity):

    class Enumerable<T> : IEnumerable<T>, IEnumerator<T> where T : struct
    {
        public IEnumerator<T> GetEnumerator()
        {
            return this;
        }

        public T Current { get; private set; }

        public bool MoveNext()
        {
            T? current = _next();
            if (current != null)
            {
                Current = (T)current;
            }
            return current != null;
        }

        public Enumerable(Func<T?> next)
        {
            _next = next ?? throw new ArgumentNullException(nameof(next));
        }

        private readonly Func<T?> _next;
    }

Now, here is the problem: when I change the constraint where T : struct to where T : notnull to also support reference types I get the following errors on the last line of the method GetPrimes:

error CS0037: Cannot convert null to 'int' because it is a non-nullable value type
error CS1662: Cannot convert lambda expression to intended delegate type because some of the return types in the block are not implicitly convertible to the delegate return type

It seems that the Enumerable constructor expects a Func<int> rather than a Func<int?> as I coded it:

Parameter info tooltip

I don't understand why, if T is int, T? is also treated as int rather than int?. The documentation says that this is the expected behavior for unconstrained type parameters but what's the point of having the notnull constraint if it doesn't allow me to treat T and T? as two different things?

Ron Inbar
  • 2,044
  • 1
  • 16
  • 26
  • `ValueType`s (`struct`s like `int`s) can't be `null`. Instead of returning `null` you could return `default`, which would be `null` for `object`s and for an `int` for instance it would be 0 (it differs from struct to struct though). Then you just check for `default(T)` instead of `null` and it works the same basically. – baltermia Jul 14 '22 at 14:05
  • 1
    Look at the context of the documentation that you linked, it's talking about `struct` and `class` constraints. An "unconstrained" type parameter there means a type parameter without a `class` or `struct` constraint. – Sweeper Jul 14 '22 at 14:07
  • `notnull` is just a check that the type argument is not a NRT or `Nullable`. The behaviour you expect isn't really possible without making changes to the CLR. – Sweeper Jul 14 '22 at 14:11
  • 1
    The point is to allow generics to be written with nullable constraints for both reference and value types -- that's the only point, it will not allow you to get around the fundamental limitation of the runtime that these have different representations. Consider making your lambda return `(bool, T)` instead, so every element comes with the end-of-sequence indicator, rather than commandeering a (possibly valid in and of itself) default for this. Or, just, you know, use iterators, as that's what they're there for, and your function idea seems to add little to nothing to the concept. – Jeroen Mostert Jul 14 '22 at 14:14
  • I asked a very specific question: how can I write generic code that treats `T` and `T?` as two different types regardless of whether `T` is a (non-nullable) value type or a non-nullable reference type. – Ron Inbar Jul 14 '22 at 14:34
  • 4
    And the very specific answer is: you cannot, because for reference types, `T` and `T?` *are* the same type, whereas for value types they are *not*, and the runtime cannot bridge this gap. You're not the first person to be disappointed by this. – Jeroen Mostert Jul 14 '22 at 14:36
  • @JeroenMostert As for the purpose of my code, it's meant to prove a point which has nothing to do with nullable types, namely that lambda functions are powerful enough to implement advanced language features like generators without requiring special syntax like `yield`. – Ron Inbar Jul 14 '22 at 14:37
  • 1
    Implementing `IEnumerable` requires no special features in the first place -- you do it yourself in your class, after all. Mixing in lambdas can be done whether or not you'd like to use an iterator yourself. If a nullable sentinel won't do it you could use *two* lambdas (`Enumerable.For(() => i < primes.Length, () => primes[i++])`), or a tuple (`Enumerable.For(() => i < primes.Length ? (true, primes[i++]) : (false, default))`), or a dedicated `Option` type to take the place of `Nullable` (`Enumerable.For(() => i < primes.Length ? primes[i++] : Option.None)`, assuming conversions). – Jeroen Mostert Jul 14 '22 at 14:53
  • Try `() => i < primes.Length ? primes[i++] : default` instead of `() => i < primes.Length ? primes[i++] : null`. – Sebastian Schumann Jul 14 '22 at 17:17
  • Ah, I think I understand now. Since generic types are compiled just like non-generic types (unlike C++ templates), and since `T` and `Nullable` require different IL code, the compiler only emits code that uses `Nullable` when `T` is explicitly constrained to be a value type (`where T : struct`). Otherwise it has to emit code that doesn't use `Nullable`, in effect ignoring the `?` when `T` is a value type. – Ron Inbar Jul 14 '22 at 19:45

1 Answers1

2

To answer your actual final question - "what's the point of having the notnull constraint ..." - the notnull constraint is part of the nullable context feature, which generally only produces compiler warnings, but (as far as I know) has no effect on the actual compiled program.

Within a nullable-enabled context, the following class will generate a compiler warning "Dereference of a possibly null reference" at c.ToString():

class A<T>
{
    public static string? B(T c)
    {
        return c.ToString();
    }
}

If you add the constraint where T : notnull to the class, that warning will disappear. Thus, the constraint allows you to keep better track of possible null reference errors.

M Kloster
  • 679
  • 4
  • 13