4

I fully understand that a System.Array is immutable.

Given that, why does it have an Add() method?

It does not appear in the output of Get-Member.

$a = @('now', 'then')
$a.Add('whatever')

Yes, I know this fails and I know why it fails. I am not asking for suggestions to use [System.Collections.ArrayList] or [System.Collections.Generic.List[object]].

mklement0
  • 382,024
  • 64
  • 607
  • 775
lit
  • 14,456
  • 10
  • 65
  • 119
  • To sort of add on to your question, if anyone knows, how do you find the interfaces a type implements using powershell? I find myself in the msdn a lot trying to figure things out. – Maximilian Burszley Aug 03 '18 at 03:20
  • 3
    @TheIncorrigible1 You can call the [`GetInterfaces()` method](https://learn.microsoft.com/dotnet/api/system.type.getinterfaces) on a [`Type` instance](https://learn.microsoft.com/dotnet/api/system.type) like so: `$a.GetType().GetInterfaces()` – Lance U. Matthews Aug 05 '18 at 18:45
  • I just found this out today. It's horrific. I wished they'd fix this: the only safety is a runtime exception, while you would definitely want static analysis here. This is some legacy crap of the worst kind... – JHBonarius May 16 '23 at 08:29

2 Answers2

7

[System.Array] implements [System.Collections.IList], and the latter has an .Add() method.

That Array implements IList, which is an interface that also covers resizable collections, may be surprising - it sounds like there are historical reasons for it[1] .

In C#, this surprise is hard to stumble upon, because you need to explicitly cast to IList or use an IList-typed variable in order to even access the .Add() method.

By contrast, since version 3, PowerShell surfaces even a type's explicit interface implementations as direct members of a given type's instance. (Explicit interface implementations are those referencing the interface explicitly in their implementation, such as IList.Add() rather than just .Add(); explicit interface implementations are not a direct part of the implementing type's public interface, which is why C# requires a cast / interface-typed variable to access them).

As a byproduct of this design, in PowerShell the .Add() method can be called directly on System.Array instances, which makes it easier to stumble upon the problem, because you may not realize that you're invoking an interface method. In the case of an array, the IList.Add() implementation (rightfully) throws an exception stating that Collection was of a fixed size; the latter is an exception of type NotSupportedException, which is how types implementing an interface are expected to report non-support for parts of an interface.

What helps is that the Get-Member cmdlet and even just referencing a method without invoking it - simply by omitting () - allow you to inspect a method to determine whether it is native to the type or an interface implementation:

PS> (1, 2).Add  # Inspect the definition of a sample array's .Add() method

OverloadDefinitions
-------------------
int IList.Add(System.Object value)

As you can see, the output reveals that the .Add() method belongs to the Ilist interface.


[1] Optional reading: Collection-related interfaces in .NET with respect to mutability

Disclaimer: This is not my area of expertise. If my explanation is incorrect / can stand improvement, do tell us.

The root of the hierarchy of collection-related interfaces is ICollection (non-generic, since v1) and ICollection<T> (generic, since v2).

(They in turn implement IEnumerable / IEnumerable<T>, whose only member is the .GetEnumerator() method.)

While the non-generic ICollection interface commendably makes no assumptions about a collection's mutability, its generic counterpart (ICollection<T>) unfortunately does - it includes methods for modifying the collection (the docs even state the interface's purpose as "to manipulate generic collections" (emphasis added)). In the non-generic v1 world, the same had happened, just one level below: the non-generic IList includes collection-modifying methods.

By including mutation methods in these interfaces, even read-only/fixed-size lists/collections (those whose number and sequence of elements cannot be changed, but their element values may) and fully immutable lists/collections (those that additionally don't allow changing their elements' values) were forced to implement the mutating methods, while indicating non-support for them with NotSupportedException exceptions.

While read-only collection implementations have existed since v1.1 (e.g, ReadOnlyCollectionBase), in terms of interfaces it wasn't until .NET v4.5 that IReadOnlyCollection<T> and IImmutableList<T> were introduced (with the latter, along with all types in the System.Collections.Immutable namespace, only available as a downloadable NuGet package).

However, since interfaces that derive from (implement) other interfaces can never exclude members, neither IReadOnlyCollection<T> nor IImmutableCollection<T> can derive from ICollection<T> and must therefore derive directly from the shared root of enumerables, IEnumerable<T>. Similarly, more specialized interfaces such as IReadOnlyList<T> that implement IReadOnlyCollection<T> can therefore not implement IList<T> and ICollection<T>.

More fundamentally, starting with a clean slate would offer the following solution, which reverses the current logic:

  • Make the major collection interfaces mutation-agnostic, which means:

    • They should neither offer mutation methods,
    • nor should they make any guarantees with respect to immutability.
  • Create sub-interfaces that:

    • add members depending on the specific level of mutability.
    • make immutability guarantees, if needed.

Using the example of ICollection and IList, we'd get the following interface hierarchy:

IEnumerable<T> # has only a .GetEnumerator() method
  ICollection<T>  # adds a .Count property (only)
   IResizableCollection<T> # adds .Add/Clear/Remove() methods
   IList<T> # adds read-only indexed access
    IListMutableElements<T> # adds writeable indexed access
    IResizableList<T> # must also implement IResizableCollection<T>
      IResizableListMutableElements<T> # adds writeable indexed access
    IImmutableList<T> # guarantees immutability

Note: Only the salient methods/properties are mentioned in the comments above.

Note that these new ICollection<T> and IList<T> interfaces would offer no mutation methods (no .Add() methods, ..., no assignable indexing).

IImmutableList<T> would differ from IList<T> by guaranteeing full immutability (and, as currently, offer mutation-of-a-copy-only methods).

System.Array could then safely and fully implement IList<T>, without consumers of the interface having to worry about NotSupportedExceptions.

mklement0
  • 382,024
  • 64
  • 607
  • 775
6

To "Add" to @mklement0's answer: [System.Array] implements [System.Collections.IList] which specifies an Add() method.

But to answer why have an Add() if it doesn't work? Well, we haven't looked at the other properties, i.e. IsFixedSize :

PS > $a = @('now', 'then')
PS > $a.IsFixedSize
True

So, a [System.Array] is just a [System.Collections.IList] that is a Fixed Size. When we look back at the Add() method, it explicitly defines that if the List is Read-Only or Fixed Size, throw NotSupportedException which it does.

I believe the essence is not, "Let's have a function that just throws an error message for no reason", or to expand on it, No other reason than to fulfill an Interface, but it actually is providing a warning that you are legitimately doing something that you shouldn't do.

It's the typical Interface ideas, you can have an IAnimal type, with an GetLeg() method. This method would be used 90% of all animals, which makes it a good reason for implementing into the base Interface, but would throw an error when you use it against a Snake object because you didn't first check the .HasFeet property first.

The Add() method is a really good method for a List Interface, because it is an essential method for Non-Readonly and Non-Fixed length lists. We are the ones being stupid by not checking that the list is not IsFixedSize before calling an Add() method that would not work. i.e. this falls into the category of $null checks before trying to use things.

HAL9256
  • 12,384
  • 1
  • 34
  • 46
  • 1
    Fair points, but you could argue that there should be an intermediate `IFixedSizeList` interface, which would avoid these problems. What obscures matters is that PowerShell implicitly exposes even the explicitly implemented `IList` members as if they were direct members of arrays, so to a casual PowerShell user it appears that `.Add()` is just another method available on an array - the interface provenance is obscured. – mklement0 Aug 03 '18 at 01:45
  • Thank you for your good explanation. I just think the tool(s) could do more for the developer to avoid writing such code. – lit Aug 03 '18 at 01:48
  • @lit: That PowerShell surfaces interface implementation as direct members _generally_ makes sense in a dynamic, type-flexible language such as PowerShell; in some cases - such as this prominent one - the unfortunate side effect is that you may walk into errors without fully understanding why. PowerShell can't really prevent these errors categorically, because it would have to anticipate all type-interface combinations, which is obviously impractical (and cherry-picking a few high-profile nonsensical combinations is not a good idea). – mklement0 Aug 03 '18 at 02:23