[Pitch] Add `insertOrRemove(_:)` Method to `Set` and `SetAlgebra` in Swift

Introduction

This proposal suggests adding an insertOrRemove(_:) method to the SetAlgebra protocol in Swift, and by extension to the Set type. This method provides a concise and intuitive way to toggle the presence of an element in a Set or any SetAlgebra conformer, either inserting it if absent or removing it if present.

While the formSymmetricDifference method can technically achieve this functionality, it is not discoverable and is primarily intended for bulk operations. Instead it seems much more common that developers must implement this functionality manually using if-else logic or custom extensions. While the existing update(with:) method partially addresses this need, it does not support true toggle behavior (removing an existing element). Adding this new method to the Swift Standard Library would simplify common workflows and improve code readability.


Motivation

The Set type is a widely used data structure in Swift, often for managing unique elements such as selected items, active states, or identifiers. Toggling the presence of an element is a common operation, but Swift currently lacks a built-in and discoverable, declarative way to perform it.

Developers frequently resort to verbose or non-intuitive conditional logic:

if set.contains(element) {
    set.remove(element)
} else {
    set.insert(element)
}
mySet.formSymmetricDifference([1])

Or custom extensions:

extension Set {
    mutating func insertOrRemove(_ element: Element) {
        if contains(element) {
            remove(element)
        } else {
            insert(element)
        }
    }
}
extension Set {
    mutating func insertOrRemove(_ element: Element) {
		mySet.formSymmetricDifference([element])
    }
}

Adding insertOrRemove(_:) to the Standard Library would:

  • Eliminate the need for repetitive boilerplate code.
  • Enhance the usability and expressiveness of the Set API.
  • Align with Swift's goals of clarity and conciseness.

Proposed Solution

Add an insertOrRemove(_:) method to SetAlgebra, which Set conforms to, offering a straightforward API to toggle the presence of an element.

  • If the element is present in the set, remove it.
  • If the element is absent, insert it.

Method Definition:

extension SetAlgebra {
    /// Toggles the membership of the given element in the set.
    /// - Parameter element: The element to toggle.
    mutating func insertOrRemove(_ element: Element) {
        if contains(element) {
            remove(element)
        } else {
            insert(element)
        }
    }
}

Example Usage:

Set

var selectedItems: Set<Int> = [1, 2, 3]

// Toggle presence of an element
selectedItems.insertOrRemove(2) // Removes 2
selectedItems.insertOrRemove(4) // Adds 4

print(selectedItems) // Output: [1, 3, 4]

OptionSet

struct ExampleOptions: OptionSet {
    let rawValue: Int
    static let option1 = ExampleOptions(rawValue: 1 << 0)
    static let option2 = ExampleOptions(rawValue: 1 << 1)
}

var options: ExampleOptions = []
options.insertOrRemove(.option1) // Adds option1
options.insertOrRemove(.option1) // Removes option1

Relationship to formSymmetricDifference

The formSymmetricDifference method can achieve similar functionality by performing a symmetric difference between the set and a single-element set:

selectedItems.formSymmetricDifference([1])

However, this usage is:

  • Non-discoverable: formSymmetricDifference is primarily associated with set algebra, making its use for toggling elements unintuitive.
  • Verbose: Requires wrapping the element in a single-element set, reducing clarity for a common operation.

The addition of insertOrRemove(_:) provides a more natural and expressive alternative.

Alternate Naming

While insertOrRemove(_:) is the most concise and intuitive name, other naming options include:

  • toggle(_:): A concise and intuitive name that emphasizes the action of flipping the membership state of the element. This naming is easy to remember and aligns with Swift’s preference for brevity.

    selectedItems.toggle(2) // Removes 2
    selectedItems.toggle(4) // Adds 4
    
  • flipMembership(_:): Emphasizes the change in membership status within the set.

    selectedItems.flipMembership(2) // Removes 2
    selectedItems.flipMembership(4) // Adds 4
    
  • invert(_:): Suggests reversing the presence of the element in the set. This name is succinct and easy to understand.

    selectedItems.invert(2) // Removes 2
    selectedItems.invert(4) // Adds 4
    
  • alterPresence(_:): Highlights the change or alteration of the element’s status within the set.

    selectedItems.alterPresence(2) // Removes 2
    selectedItems.alterPresence(4) // Adds 4
    

Detailed Design

The insertOrRemove(_:) method will be declared as an extension of the SetAlgebra protocol, allowing it to be used with all types conforming to SetAlgebra, including Set, OptionSet, and any custom types.

This design aligns with existing SetAlgebra methods like union, intersection, and subtracting, ensuring consistency and extensibility across the protocol.

This proposal is purely additive and introduces no breaking changes. It enhances the Set and SetAlgebra API by providing a new, concise operation without affecting current functionality.


Declaring on SetAlgebra: Benefits and Considerations

Benefits

  1. Broader Applicability
    Declaring the method on SetAlgebra extends its functionality to all conforming types, including:
    • Set
    • OptionSet
    • Custom types conforming to SetAlgebra
  2. Consistency
    Many existing methods like union, intersection, and subtracting are defined on SetAlgebra. Adding insertOrRemove(_:) aligns with this pattern and provides consistency in the API design.
  3. Future-Proofing
    Including this in SetAlgebra anticipates future types that might conform to the protocol, ensuring they automatically benefit from the method.

Considerations

  1. Ambiguity of Semantics
    The behavior of insertOrRemove might not always feel intuitive for non-Set types. For example, with OptionSet, the term “insert or remove” might feel less clear compared to a toggle of bitmask-style options.

  2. Complexity of Scope
    Broader applicability increases the need for testing and design considerations across all conformers, potentially slowing down the adoption of the feature.

Alternatives Considered

  1. Custom Extensions
    Many developers already implement custom extensions to achieve this functionality. However, relying on custom extensions leads to inconsistent implementations across projects and misses the opportunity to standardize a common operation in the Swift language.

  2. Rely on formSymmetricDifference
    Although formSymmetricDifference technically supports this functionality, its usage for toggling individual elements is not intuitive and deviates from its primary purpose.

  3. Use update(with:)
    The update(with:) method does not fulfill the requirements of a true toggle. It adds the element if not present but does not remove it if already present.

  4. Limit to Set
    Limiting the method to Set simplifies the proposal but reduces its utility and future applicability.

  5. Do Nothing
    The community could continue using custom solutions. However, this adds unnecessary friction and verbosity for a simple, common operation.


Conclusion

The addition of an insertOrRemove(_:) method to Set and SetAlgebra represents a small but impactful improvement to Swift’s Standard Library. By providing a clear, expressive, and discoverable way to toggle the presence of an element, this method eliminates boilerplate code, improves readability, and aligns with Swift’s design principles of clarity and conciseness.

Declaring the method on SetAlgebra extends its utility to all conforming types, such as Set and OptionSet, ensuring broader applicability and consistency across the API. It also future-proofs the method for custom SetAlgebra conformers, making it a versatile and extensible addition.

The method name insertOrRemove(_:) is both descriptive and intuitive, but alternate names such as invert(:) or addOrRemove(_:) could be considered during the Swift Evolution review to balance brevity and clarity.

This proposal:

  • Simplifies a common operation, replacing verbose manual logic with a single, expressive method.
  • Enhances discoverability by providing a dedicated API for toggling membership of an element.
  • Aligns with existing SetAlgebra patterns, ensuring consistency with Swift’s Standard Library design.

By adopting this proposal, Swift will further empower developers with a small yet meaningful improvement that enhances usability, reduces boilerplate, and promotes clarity in working with Set-like types.

Thank you for considering this proposal!


10 Likes

Love it, +1! I have used similar extension methods in my own codebases.

I strongly disagree. I think I would prefer toggleInclusion(of:) or something similar, but I don't want to spend too much time bikeshedding personally.

13 Likes

This looks like ^ and ^= operation to me, either with the set or with the element for the rhs. Could it be named like this?

I often implement this as toggle(_:).

8 Likes

@JuneBash @mtsrodrigues I went back and forth with the naming. I’m not to attached to any of them.

@tera are you referring to operator overloading? Can you provide an implementation example for clarity?

I also implement this as SetAlgebra.toggle(_:), but I go a bit further: It returns a discardable Bool to indicate whether the set contains the element or not after toggling it. This matches the general pattern of insert(_:), update(with:), and remove(_:). It also makes it a bit more convenient to perform logic based on the operation, such as:

if mySet.toggle(someValue) {
    print("The value is now present in the set")
} else {
    print("The value is no longer present in the set")
}

(and FWIW I vastly prefer the naming of toggle(_:), since it matches Bool.toggle(), which is conceptually similar)

22 Likes

Sounds like toggle(_:) is the preferred name so far! Funny because thats what I originally went with and didn't think it would be descriptive enough.

@davedelong I like the inclusion of your discardable result. That's something I would like to see in the implementation as well.

Can you give some real-world examples where this need frequently arises?

I’m not doubting that it does, I just have not personally encountered a situation where I want to either add or remove an element from a set but I don’t know which.

I think the proposal’s motivation would be stronger if it included some actual motivating uses.

6 Likes

Here's one simple case:

@Observable
class Model {
  private(set) var items: [Item]

  private var selectedItemIDs: Set<Item.ID>

  func itemIsSelected(_ item: Item) -> Bool {
    selectedItemIDs.contains(item.id)
  }

  func itemTapped(_ item: Item) {
    selectedItemIDs.toggle(item.id)
  }
  //...
}

struct MyView: View {
  let model: Model

  var body: some View {
    List(model.items) { item in 
      Text(item.name)
        .background(model.itemIsSelected(item) ? .blue : .clear)
        .onTapGesture { model.itemTapped(item) }
    }
  }
}
8 Likes

I do this all the time when I'm building a picker UX (such as a list of Toggle() views in SwiftUI) that's backed by a Set<SomeFilterOption>, for example.

3 Likes

@Nevin, @JuneBash provided the exact example I was thinking of. Filters and tags are where I use this the most.

import SwiftUI

struct TagSelectionView: View {
    let allTags: [String] = ["Swift", "iOS", "UIKit", "SwiftUI", "Combine", "Xcode"]
    @State private var selectedTags: Set<String> = []

    var body: some View {
        VStack {
            Text("Select Tags")
                .font(.headline)

            // SwiftUI Grid to display tags
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 10) {
                ForEach(allTags, id: \.self) { tag in
                    TagView(tag: tag, isSelected: selectedTags.contains(tag))
                        .onTapGesture {
                            // Toggle tag selection
                            selectedTags.insertOrRemove(tag)
                        }
                }
            }
            .padding()

            // Display selected tags
            Text("Selected Tags: \(selectedTags.joined(separator: ", "))")
                .font(.subheadline)
                .padding()
        }
    }
}

struct TagView: View {
    let tag: String
    let isSelected: Bool

    var body: some View {
        Text(tag)
            .padding()
            .background(isSelected ? Color.blue : Color.gray.opacity(0.3))
            .foregroundColor(isSelected ? .white : .black)
            .cornerRadius(8)
            .overlay(
                RoundedRectangle(cornerRadius: 8)
                    .stroke(isSelected ? Color.blue : Color.gray, lineWidth: 1)
            )
    }
}

struct ContentView: View {
    var body: some View {
        TagSelectionView()
    }
}

2 Likes

For me this would be the most straightforward way:

var a, b: Set<Int>
a + b    // union
a & b    // intersection
a ^ b    // xor
a - b    // difference
a \ b    // symmetric difference
!a or ¬a // inverse
a + 42   // same as `a + [42]`
a & 42   // same as `a & [42]`
a ^ 42   // same as `a ^ [42]`
...

Similarly for op= versions.

I think this is an interesting example of how declarative frameworks make you think differently about how UIs work. Under the traditional stateful GUI approach, the control would be responsible for inverting its own state, and I would write an action handler that pushed that new state to the model. You could of course implement the action handler to invert the model state, but that risks the model and UI being permanently out of sync.

The upshot for this conversation is that many of us might have developed an intuition for which operations are commonly used that is greatly influenced by the 40 years of GUI framework design spanning 1980–2020.

Operators are much more difficult to use as keypaths. This is why Boolean.toggled was added even though prefix operator ! already existed.

4 Likes

I do think there’s an efficiency argument for making this more than just an extension: for OptionSets it doesn’t matter, but a Set has to do two hash lookups here instead of just one. Unfortunately, then it’s not backwards-deployable, so there is a trade-off. (And every new customization point increases code size for adopting types forever…)

6 Likes

Isn’t there some trick where the stdlib provides a @_alwaysEmitIntoClient default implementation that’s only @available in stdlib versions before the protocol requirement was added?

1 Like

BitSet has a subscript that could be generalized. The choice of using insert(_:) or update(with:) would need to be documented.

extension SetAlgebra {
  public subscript(member member: Element) -> Bool {
    get {
      contains(member)
    }
    set {
      if newValue {
        insert(member) // FIXME: update(with: member)
      } else {
        remove(member)
      }
    }
  }
}

For example:

var selectedItems: Set<Int> = [1, 3]

// contains(1)
selectedItems[member: 1]

// insert(2)
selectedItems[member: 2] = true

// remove(3)
selectedItems[member: 3] = false

// insertOrRemove(4)
selectedItems[member: 4].toggle()
8 Likes

That would be nice, I don’t actually know myself!

Yep, I have that too, and it's arguably more useful than SetAlgebra.toggle(_:). Both have their place, although I avoided bringing it up to keep this thread a bit more on-topic.

2 Likes

To align with that pattern more fully, it'd be useful for it to return a tuple with the second value being either the removed element (if one was removed) just like remove(_:) or the element after the inserted element (if one was inserted) just like insert(_:).

(You could still use it ergonomically in the way you show since the first value of the returned tuple would be inserted: Bool.)

5 Likes