26

I have a private HashSet<string> which is the backing field of a read-only property which should return a read-only collection such that callers cannot modify the collection. So I tried to:

public class MyClass
{
    private readonly HashSet<string> _referencedColumns;

    public ICollection<string> ReferencedColumns { 
        get { return new ReadOnlyCollection<string>(_referencedColumns); }
    }

This does not compile as ReadOnlyCollection accepts a IList<T> which is not implememted by HashSet<T>. Is there another wrapper I can use to save me from copy the items? For my purpose it is enough to just return something implementing ICollection<T> (instead of IList<T>) which is implemented by the HashSet<T>.

Dejan
  • 9,150
  • 8
  • 69
  • 117
  • If it's important thet tha callers cannot modify the return, have a look at [Immutability and ReadOnlyCollection](https://blogs.msdn.microsoft.com/jaredpar/2008/04/22/immutability-and-readonlycollectiont/) and maybe [this question(http://stackoverflow.com/questions/285323/best-practice-how-to-expose-a-read-only-icollection) – stuartd Apr 28 '16 at 18:47

4 Answers4

28

Consider exposing the property as the type IReadOnlyCollection<> instead, which will provide a read-only view of the HashSet<>. This is an efficient way of implementing this, since the property getter will not require a copy of the underlying collection.

This will not prevent someone from casting the property to a HashSet<> and modifying it. If you are concerned with that, consider return _referencedColumns.ToList() in the property getter, which will create a copy of your underlying set.

Bas
  • 26,772
  • 8
  • 53
  • 86
  • 1
    Thanks. So is there no wrapper that saves the copying overhead and that cannot be cast back? – Dejan Apr 28 '16 at 18:54
  • 10
    Just for the records: casting to `IReadOnlyCollection<>` just works with .NET 4.6 and above (see: http://stackoverflow.com/a/32762752/331281). – Dejan Apr 28 '16 at 19:04
  • 1
    Good call. This will still use `HashSet`'s indexed behavior, although that is no longer obvious. Calls to `IReadOnlyCollection.Contains()` reek strongly of a linear-time implementation, even if that is not necessarily the case. – Timo Sep 21 '17 at 12:58
  • The framework difference tripped me. In VS 2017 if you navigate to HashSet class defn it is shown as implementing IReadOnlyCollection but compiler throws the error, so was wondering what was happening for a while. My project was on 4.5 – Senthil Ramanathan Apr 05 '18 at 08:52
  • 7
    Downvoting because `IReadOnlyCollection` doesn't contain a `Contains()` method, which seems like a pretty crucial ingredient of a read-only view of a hash set. – Walt D Aug 21 '19 at 00:46
  • @WaltD maybe you're on an older version of .NET because `IReadOnlyCollection.Contains()` does exist in 4.6.1 – drzaus Jun 04 '20 at 16:26
  • @drzaus I'm on .NET Core 3.1 and it does not have a `Contains` method. Are you sure you're not thinking of the `IEnumerable` extension method? One could of course use that, but it doesn't have the same O(1) performance. – Walt D Jun 04 '20 at 20:09
  • 1
    @WaltD `IEnumerable` extension method is trying to cast enumerable to `ICollection` firstly. So it will use `Contains` from `HashSet`. – Alex Jul 06 '20 at 18:07
  • @drzaus Yeah looks like you're correct in the case of the overload that doesn't have an IEqualityComparer parameter. I remember checking the source code to see if it did that, but I must have been looking at the wrong source code. I stand corrected and retract my downvote. (Though I still think it's odd that IReadOnlyCollection doesn't have a Contains method.) EDIT: Well I guess I can't actually retract my downvote unless the post is edited... odd. – Walt D Jul 07 '20 at 03:20
  • In newer versions of .NET, there also is `IReadOnlySet<>` which has a `Contains` method. – Petr R. Jun 05 '23 at 19:04
7

While it is not readonly, Microsoft released a nuget package called System.Collections.Immutable that contains an ImmutableHashSet<T> which implements IImmutableSet<T> which extendsIReadOnlyCollection<T>

Quick usage sample :

public class TrackedPropertiesBuilder : ITrackedPropertiesBuilder
{
    private ImmutableHashSet<string>.Builder trackedPropertiesBuilder;

    public TrackedPropertiesBuilder()
    {
        this.trackedPropertiesBuilder = ImmutableHashSet.CreateBuilder<string>();
    }

    public ITrackedPropertiesBuilder Add(string propertyName)
    {
        this.trackedPropertiesBuilder.Add(propertyName);
        return this;
    }

    public IImmutableSet<string> Build() 
        => this.trackedPropertiesBuilder.ToImmutable();
}
Uwy
  • 457
  • 7
  • 13
  • The only concern I see is that the collections under `System.Collections.Immutable` is that (I believe) they are intended for thread safety, and such classes can have negative performance implications in situations where thread safety is not needed. – ErikE Jan 01 '19 at 05:37
  • 2
    @ErikE Immutability is a general design pattern not only designed for thread safety, performance implications are, IMO, more coupled to the algorithm using those collection inefficiently rather than the collection itself – Uwy Jan 08 '19 at 14:08
  • 2
    Be careful ImmutableHashSet is [several times slower](https://github.com/dotnet/runtime/issues/29085) than HashSet! – Kirsan Dec 22 '21 at 10:28
6

You can use the following decorator to wrap the hash set and return an ICollection<T> that is read-only (the IsReadOnly property returns true and modification methods throw a NotSupportedException as specified in the contract of ICollection<T>):

public class MyReadOnlyCollection<T> : ICollection<T>
{
    private readonly ICollection<T> decoratedCollection;

    public MyReadOnlyCollection(ICollection<T> decorated_collection)
    {
        decoratedCollection = decorated_collection;
    }

    public IEnumerator<T> GetEnumerator()
    {
        return decoratedCollection.GetEnumerator();
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return ((IEnumerable) decoratedCollection).GetEnumerator();
    }

    public void Add(T item)
    {
        throw new NotSupportedException();
    }

    public void Clear()
    {
        throw new NotSupportedException();
    }

    public bool Contains(T item)
    {
        return decoratedCollection.Contains(item);
    }

    public void CopyTo(T[] array, int arrayIndex)
    {
        decoratedCollection.CopyTo(array, arrayIndex);
    }

    public bool Remove(T item)
    {
        throw new NotSupportedException();
    }

    public int Count
    {
        get { return decoratedCollection.Count; }
    }

    public bool IsReadOnly
    {
        get { return true; }
    }
}

And you can use it like this:

public class MyClass
{
    private readonly HashSet<string> _referencedColumns;

    public ICollection<string> ReferencedColumns { 
        get { return new MyReadOnlyCollection<string>(_referencedColumns); }
    }
    //...

Please note that this solution will not take a snapshot of the HashSet, instead it will hold a reference to the HashSet. This means that the returned collection will contain a live version of the HashSet, i.e., if the HashSet is changed, the consumer that obtained the read only collection before the change would be able to see the change.

Yacoub Massad
  • 27,509
  • 2
  • 36
  • 62
  • Thanks! Sure, I can write my own. I was wondering if the BCL has something in its pocket. BTW, would it be better if your `MyReadOnlyCollection` would implement `IReadOnlyCollection<>`? – Dejan Apr 28 '16 at 19:49
  • You're welcome. It depends on the consumer. Does it prefer `IReadOnlyCollection`? – Yacoub Massad Apr 28 '16 at 19:52
  • `IReadOnlyCollection` is sufficient and perfectly documents to its callers what to expect. I should have used that in the first place in my question but I won't change that now to keep the history. – Dejan Apr 28 '16 at 23:18
  • Worth noting that `ICollection` works for .Net 4, whereas `IReadOnlyCollection` requires a .Net 4.5+. As a result, the historical implementaion can sometimes be more useful. – tobriand Nov 22 '18 at 12:40
6

This is quite simple, I don't know why Microsoft didn't provide this, but I will post my implementation based on IReadOnlyCollection<T>, along with extension method for completeness.

public class MyClass {
    private readonly HashSet<string> _referencedColumns;

    public IReadonlyHashSet<string> ReferencedColumns => _referencedColumns.AsReadOnly();
}

/// <summary>Represents hash set which don't allow for items addition.</summary>
/// <typeparam name="T">Type of items int he set.</typeparam>
public interface IReadonlyHashSet<T> : IReadOnlyCollection<T> {
    /// <summary>Returns true if the set contains given item.</summary>
    public bool Contains(T i);
}

/// <summary>Wrapper for a <see cref="HashSet{T}"/> which allows only for lookup.</summary>
/// <typeparam name="T">Type of items in the set.</typeparam>
public class ReadonlyHashSet<T> : IReadonlyHashSet<T> {
    /// <inheritdoc/>
    public int Count => set.Count;
    private HashSet<T> set;

    /// <summary>Creates new wrapper instance for given hash set.</summary>
    public ReadonlyHashSet(HashSet<T> set) => this.set = set;

    /// <inheritdoc/>
    public bool Contains(T i) => set.Contains(i);

    /// <inheritdoc/>
    public IEnumerator<T> GetEnumerator() => set.GetEnumerator();
    /// <inheritdoc/>
    IEnumerator IEnumerable.GetEnumerator() => set.GetEnumerator();
}

/// <summary>Extension methods for the <see cref="HashSet{T}"/> class.</summary>
public static class HasSetExtensions {
    /// <summary>Returns read-only wrapper for the set.</summary>
    public static ReadonlyHashSet<T> AsReadOnly<T>(this HashSet<T> s)
        => new ReadonlyHashSet<T>(s);
}
Pawcio
  • 399
  • 5
  • 15
  • This is quite similar to Bas's answer, but the difference is that it does not just simply cast down a `HashSet` to an `IReadOnlyCollection` which can be cast back up to the original collection, it actually encapsulates/wraps the source `set` inside a new `ReadonlyHashSet` class (`private HashSet set;`), exposing only the interface of `IReadOnlyCollection`. It cannot be cast to a `HashSet` because it is *not-a* `HashSet`. Chapeau bas, mister Pawcio! – Paul-Sebastian Manole Apr 08 '21 at 15:31