Repository: dotnet/winforms

Issue #2644: Proposal: implement generic IList<T> on collection types
Created: 2020-01-06T21:04:33Z
Historically WinForms existed before generic collections, and when generics were introduced the collection types in WinForms never were updated to implement the generic IList<T> interface. I think its time to revisit all collection types and implement IList<T> for an appropriate type T.
For maximum compatibility the T in IList<T> should match the already existing indexer on the collection type, because binding logic will currently orient itself on the indexer to determine the type of the list, but if you start implementing IList<T> it will prefer that over the indexer. To avoid changing how collection types behave during binding you should use the indexer type as T.
Some advantages of implementing IList<T> in addition to IList:

using LINQ expressions can make use of additional optimizations - you could use LINQ on the existing IList collections by calling Cast<T>() or OfType<T>() first but this hides the type of the collection so LINQ cannot make use of e.g. Count or CopyTo interface methods.
foreach on an untyped list does implicit typecasts on every element - implementing the generic IList<T> avoids these
better integration with modern APIs and Analyzers which are more likely to be based on the generic IList<T>
better integration with nullability annotations

Since it turned out there are many trade-offs when implementing generic collections here is a breakdown of breaking changes vs. their benefits:

Breaking changes grouped by benefit/scenario
Convenience
(1) var in foreach loops is not supported: foreach (var child in parent.Controls) defaults to child being object (same for every other collection type, not just the control collection). You have to write out the collection content type manually in every loop.
Fixing this requires implementing generic IEnumerable<T> and changing the return type of the public GetEnumerator from non-generic IEnumerator to generic IEnumerator<T> because foreach picks up the public method in favor of interfaces. I assume this breaks binary compatibility.
(2) You can use LINQ without prefixing the collection with a Cast<T>() or OfType<T>() LINQ call. Currently you have to write panel.Controls.Cast<Control>() to access the control collection, repeating the content type same as in the foreach loop.
Fixing this does not require a breaking change, you can implement IEnumerable<T> explicitely without breaking binary compatibility (if I understood correctly adding an interface is not breaking compatibility).
Performance
(3) LINQ expressions such as Last() or ToList() will iterate over the whole collection since they don't see the non-generic IList. Implementing generic IList will allow more efficient access.
Fixing this requires implementing IList<T> but it can be implemented explicitely, so theoretically it can be done without breaking binary compatibility.
Practically there is a problem where some collection types use virtual methods, theoretically allowing 3rd party subclasses. Explicitely implementing IList<T> without requiring an update of subclasses leads to very weird and inefficient implementations of bool ICollection<T>.Remove(T item) because the current virtual methods won't tell whether an item was removed.
Analyzers
(4) Analyzers are usually only written with generic collections in mind. This actually came up during writing the PR, xunit only has support for generic collections, its analyzers won't pick up on antipatterns if you are using non-generic collections.
For adding analyzer support it probably should be enough implementing IList<T> explicitely so it probably has no extra cost and is just a benefit if you decide to implement generic collections at all.
Nullability
(5) Nullability checks on foreach loops will look at the public GetEnumerator, nullability checks for LINQ and other extension methods will look at the interface.
Most collections can't contain null, but you have no way to annotate the nullability on non-generic IList. Adding support for nullability to foreach loops requires a breaking change because you need to change the return type of public GetEnumerator methods to the generic version.
(6) some collection classes expose non-generic backing collections to subclasses. These are holding back the WinForms code base from modernizing itself (including proper use of nullability annotations), since you can't change backing collections to a generic type if they are exposed the way they are currently.


Benefits grouped by amount of breaking change
Benefits are incremental in the order I list them.
explicit IEnumerable<T>
Implement only IEnumerable<T> as explicit interface implementation on collection classes.

allows using LINQ expressions without prefixing them with a Cast<T>() or OfType<T>()
enables some analyzers to assist the user (analyzers are often only written for generic collections)
nullability annotations for LINQ expressions (but not foreach loops)

explicit IList<T>
Implement IList<T> but make the implementation not public unless the method already exists with the exact signature. All other methods are implemented as explicit interface implementations.

allows fast access via LINQ e.g. for Last() and ToList() extension methods
enables more analyzers to assist the user

changing GetEnumerator signature to a generic type
This is the minimum breaking change you have to take for additional benefits.

foreach loops can start using var
foreach loops see nullability annotations

changing void Remove() signature to return boolean
For non-virtual Remove() methods this is optional, you can always do a private or internal Remove() implementation which returns bool, but for virtual Remove() methods this is a major breaking change.

simplifies the explicit implementation of IList<T>.Remove massively, including better performance - without this change you have to double-check the contents for removal

change backing collection types
Some collections expose their (non-generic) backing collection to subclasses. Changing this is obviously a major breaking change.

the WinForms codebase itself would probably the major receiver of any benefits you get from this, as without this changes you are forced to use non-generic collections internally (at least in parts of the codebase which expose backing collections). This means nullability annotations within WinForms itself will be missing around those parts if you can't change backing collections to generic types.


❗️ This may impact VS Designer, and this impact will have to be assessed.

Duplicate issue #5509: API Proposal: Expose a base collection that prevents null items
Created: 2021-08-23T22:52:57Z
We have a number of collections in WinForms that are currently untyped but also attempt to disallow nulls (either in the collections themselves or in the consumers).
We have an internal base collection we're using that we should consider making public and consider deriving from in our public collections.
namespace System.Windows.Forms
{
    /// <summary>
    ///  Collection that protects against inserting null objects.
    /// </summary>
    internal abstract class NonNullCollection<T>
        : IList<T>, ICollection<T>, IEnumerable<T>, IEnumerable, IList, ICollection, IReadOnlyList<T>, IReadOnlyCollection<T>
        where T : class
    {
        public NonNullCollection();
        public NonNullCollection(IEnumerable<T> items);

        // These would match the behavior of `System.Collections.CollectionBase` as we frequently use it and would mitigate replacing
        // that base class on unsealed Windows Forms collections. We could have both typed and untyped (original) or go with only
        // one or the other.
        protected virtual void OnClear();
        protected virtual void OnClearComplete();
        protected virtual void OnInsert(int index, T value);
        protected virtual void OnInsertComplete(int index, T value);
        protected virtual void OnRemove(int index, T value);
        protected virtual void OnRemoveComplete(int index, T value);
        protected virtual void OnSet(int index, T oldValue, T newValue);
        protected virtual void OnSetComplete(int index, T oldValue, T newValue);
        protected virtual void OnValidate(T value);

        // The rest of the methods are all for the inherited interfaces and add null validation on manipulation of the internal List<T>
        public T this[int index];
        public int Count;
        public bool IsReadOnly;
        public void Add(T item);
        public void Clear();
        public bool Contains(T item);
        public void CopyTo(T[] array, int arrayIndex);
        public IEnumerator<T> GetEnumerator();
        public int IndexOf(T item);
        public void Insert(int index, T item);
        public bool Remove(T item);
        public void RemoveAt(int index);
        IEnumerator IEnumerable.GetEnumerator();
        public void AddRange(IEnumerable<T> items);
        int IList.Add(object? value);
        bool IList.Contains(object? value);
        int IList.IndexOf(object? value);
        void IList.Insert(int index, object? value);
        void IList.Remove(object? value);
        object? IList.this[int index];
        void ICollection.CopyTo(Array array, int index);
        private string DebuggerDisplay;
        bool IList.IsFixedSize;
        object ICollection.SyncRoot;
        bool ICollection.IsSynchronized;
    }
}
Any place we just implement IList would move to this collection (presuming they don't like nulls), such as the following:
namespace System.Windows.Forms
{
    public partial class ListViewItem
    {
-        public class ListViewSubItemCollection : IList
+        public class ListViewSubItemCollection : NonNullCollection<ListViewSubItem>
    }
}
We should consider moving class that derive from CollectionBase to this. It is a breaking change if you're casting directly to CollectionBase. It is also a breaking change if you derive from these. We could mitigate the derivation case by providing the same virtuals CollectionBase has.
We can also consider introducing an adapter CollectionBase that would still allow casting the collections against CollectionBase. If we did that we'd depreciate the cast operator out of the gate to discourage using it (as it would have to copy the collection into the ArrayList in CollectionBase and sync the two). I'm not fond of this and think we should just take the breaking change.
One other possibility is to extend this proposal to include modifications to CollectionBase to allow providing your own backing collection, but as the current one is exposed via protected ArrayList CollectionBase.InnerList { get; } that probably won't fly.
For example:
namespace System.Windows.Forms.Design.Behavior
{
-    public sealed class BehaviorServiceAdornerCollection : CollectionBase
+    public sealed class BehaviorServiceAdornerCollection : NonNullCollection<Adorner>
-    public class GlyphCollection : CollectionBase
+    public class GlyphCollection : NonNullCollection<Glyph>
}
This is just a start on this to get initial thoughts down and start gathering feedback.

Closing comment (2021-11-09T00:47:52Z): This is related to #2644
/cc: @weltkante