はじめに
コードを読んでいて UnionWith というメソッドを見かけました。
targetIds.UnionWith(additionalIds);
名前からして「和集合(Union)」だろうと予想はついたのですが、LINQ の Union とは別物なのか、戻り値をどう受け取るのかがすぐには出てこなかったので、あらためて調べて整理しました。
UnionWith とは
UnionWith は HashSet<T> などが持つ、自分自身に指定したコレクションの要素をマージして和集合にするメソッドです。
var a = new HashSet<int> { 1, 2, 3 };
var b = new[] { 3, 4, 5 };
a.UnionWith(b);
// a の要素 → { 1, 2, 3, 4, 5 }
3 は両方に含まれていますが、結果には1つだけ残ります。集合なので重複は持たない、という当たり前の挙動です。
以降のコメントも「集合としてどの要素を持っているか」を書いたもので、並び順ではありません。HashSet<T> は列挙順を保証しないので、順序が必要なら SortedSet<T> や OrderBy を使うことになります。
引数の型は IEnumerable<T> なので、配列でも List<T> でも渡せます。また、渡した側の b は変更されません。
正確には UnionWith は ISet<T> インターフェースで定義されているメソッドで、HashSet<T> や SortedSet<T> がこれを実装しています。
戻り値がない(呼び出した側が書き換わる)
自分が一瞬混乱したのがここでした。
var result = a.UnionWith(b); // NG:UnionWith は void
UnionWith の戻り値は void です。新しい集合を作って返すのではなく、呼び出したインスタンス自身を書き換えます。
var a = new HashSet<int> { 1, 2, 3 };
a.UnionWith(new[] { 3, 4, 5 });
// 結果は a 自身に入っている
foreach (var x in a) Console.Write($"{x} ");
「元の集合を更新する」メソッドなので、元の内容を残したい場合は事前にコピーを取っておく必要があります。
var original = new HashSet<int> { 1, 2, 3 };
var merged = new HashSet<int>(original); // コピーを作ってから
merged.UnionWith(new[] { 3, 4, 5 }); // コピー側を書き換える
LINQ の Union との違い
名前が似ていて紛らわしいのですが、Enumerable.Union とは別物です。
var a = new HashSet<int> { 1, 2, 3 };
var b = new[] { 3, 4, 5 };
// LINQ:新しいシーケンスを返す。a は変わらない
IEnumerable<int> c = a.Union(b);
// HashSet:a 自身が書き換わる。戻り値なし
a.UnionWith(b);
違いをまとめると、Union は元のコレクションに手を触れず新しいシーケンスを返す遅延評価のメソッドで、UnionWith は自分自身を書き換える破壊的なメソッドです。
a が HashSet<int> の場合、a.Union(b) と a.UnionWith(b) はどちらも書けてしまうので、書き換わってほしくない場面で UnionWith を呼んでしまうと事故になりそうです。手元の変数をそのまま更新したいなら UnionWith、別の変数として結果を得たいなら Union という使い分けになると思いました。
等値判定は何で決まるか
UnionWith が「重複」と見なす基準は、その HashSet<T> が使っている IEqualityComparer<T> に従います。コンストラクタで指定しなければ EqualityComparer<T>.Default が使われます。
int や string のように既定の比較で問題ないものはそのままで良いのですが、独自のコンパレーターを指定していない場合、自作クラスでは Equals と GetHashCode の実装次第で結果が変わることになります。「同じ ID なら同一とみなしてマージしたい」というつもりで UnionWith を呼んでも、比較が参照の同一性のままだと別要素として両方残ってしまいます。
文字列で大文字小文字を区別したくない、といった場合はコンストラクタでコンパレーターを渡します。
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "Alice" };
names.UnionWith(new[] { "alice", "Bob" });
// names の要素 → { "Alice", "Bob" }("alice" は重複扱い)
コンパレーターは集合ごとに保持されるものなので、先ほどのコピーを作る書き方にも影響します。new HashSet<T>(original) は元の集合のコンパレーターを引き継がず既定の比較に戻るため、独自のコンパレーターを使っているなら new HashSet<T>(original, original.Comparer) のようにコピー先にも同じものを渡す必要があります。
LINQ の Union はコンパレーターを引き継がない
ここが Union と UnionWith のもう1つの違いで、調べていて一番意外だった点です。
UnionWith は呼び出したインスタンスが持つコンパレーターに従いますが、Union は引数でコンパレーターを渡さない限り既定の比較を使います。左辺が HashSet<T> であっても、そのコンパレーターは引き継がれません。
var a = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "Alice" };
var b = new[] { "alice" };
var c1 = a.Union(b);
// → "Alice" と "alice" の2要素(既定の比較なので別物扱い)
var c2 = a.Union(b, a.Comparer);
// → "Alice" のみ(HashSet と同じ比較規則)
HashSet<T>.Comparer で自身のコンパレーターを取得できるので、同じ比較規則を使いたい場合はこれを渡します。破壊的かどうかだけでなく、判定基準まで変わりうる点は覚えておきたいところです。
他の集合演算メソッド
UnionWith の仲間として、集合演算のメソッドが一通り揃っています。どれも同じく自分自身を書き換えるタイプです。
// 和集合
var s1 = new HashSet<int> { 1, 2, 3 };
s1.UnionWith(new[] { 3, 4 }); // → { 1, 2, 3, 4 }
// 積集合(両方に含まれるものだけ残す)
var s2 = new HashSet<int> { 1, 2, 3 };
s2.IntersectWith(new[] { 2, 3, 9 }); // → { 2, 3 }
// 差集合(引数に含まれるものを取り除く)
var s3 = new HashSet<int> { 1, 2, 3 };
s3.ExceptWith(new[] { 2 }); // → { 1, 3 }
// 対称差(どちらか片方にしかないものを残す)
var s4 = new HashSet<int> { 1, 2, 3 };
s4.SymmetricExceptWith(new[] { 3, 9 }); // → { 1, 2, 9 }
書き換えではなく判定だけしたい場合は、IsSubsetOf(部分集合か)、IsSupersetOf(スーパーセットか)、Overlaps(共通要素があるか)、SetEquals(同じ集合か)といった bool を返すメソッドがあります。
まとめ
-
UnionWithはHashSet<T>などが持つ、自分自身を和集合に更新するメソッド - 戻り値は
voidで、呼び出したインスタンスが書き換わる。元の内容を残したいならコピーしてから呼ぶ - LINQ の
Unionは新しいシーケンスを返す別物。更新したいならUnionWith、結果を別に得たいならUnion -
Unionはコンパレーターを引き継がないので、HashSet<T>と同じ比較規則にしたいならa.Union(b, a.Comparer)のように明示的に渡す - コピーを作る
new HashSet<T>(original)も既定の比較に戻るため、独自のコンパレーターを使っているなら第2引数で渡す -
IntersectWith(積集合)、ExceptWith(差集合)、SymmetricExceptWith(対称差)も同じく破壊的なメソッド - 重複と見なす基準は
IEqualityComparer<T>次第なので、自作クラスを入れるときはEquals/GetHashCodeの実装に注意する
名前が似ている LINQ の Union と混同しやすいですが、UnionWith / IntersectWith / ExceptWith / SymmetricExceptWith はいずれも現在の集合を書き換える操作、とまとめて覚えておくと区別しやすいと感じました。
参考になったら いいね や ストック をお願いします!
同じような疑問を持ったことがある方のコメントもお待ちしています。
参考
- HashSet<T>.UnionWith(IEnumerable<T>) メソッド - Microsoft Learn
- HashSet<T> クラス - Microsoft Learn
- HashSet<T>.Comparer プロパティ - Microsoft Learn
- ISet<T> インターフェイス - Microsoft Learn
- Enumerable.Union メソッド - Microsoft Learn
関連リンク
技術ブログでも学びや検証内容をまとめています。