Making AnyHashable Sendable

Say I have a type I'd like to conform to Sendable:

public struct Foo: Sendable {
  // other Sendable members...
  private let bar: AnyHashable
}

This doesn't work because AnyHashable cannot be Sendable. Doing so would limit its utility only to Sendable types as there is no generic constraint for it to be conditionally Sendable on.

Looking at it's implementation, nothing stands out to me as non-sendable. If it is constructed from a Sendable type, is it safe to treat it as Sendable? Is this safe to do:

public struct Foo: @unchecked Sendable {
  // other Sendable members...
  private let bar: AnyHashable

  public init<T: Hashable & Sendable>(bar: T) {
    self.bar = AnyHashable(bar)
  }
}
1 Like

AnyHashable is a wrapper around any value whose type conforms to Hashable. Some types are Hashable but not Sendable. So no, AnyHashable cannot be made Sendable.

EDIT: your wrapper ought to be safe, but in theory AnyHashable is allowed to use a non-Sendable implementation (say, if it internally used an NSMutableString to represent a String, though that would be weird), so I’m not sure I’d wholeheartedly say to risk it.

4 Likes

You can also try to mark the variable itself as nonisolated (assuming you are building from 5.10 and up) as opposed to marking the whole type as unchecked. This preserves strict concurrency checking for the remaining properties (which is good).

public struct Foo: Sendable {
  // other Sendable members...
  nonisolated(unsafe) private let bar: AnyHashable

  public init<T: Hashable & Sendable>(bar: T) {
    self.bar = AnyHashable(bar)
  }
}

Ahh… correct. Any unsafe workaround is unsafe unsafe (as opposed to safe unsafe).

If you really needed sendable access… you might need to make the type itself sendable as an actor (or a struct isolated to a global actor).

So long as I am able to prove that any instantiation of Foo.bar is done with a Sendable type (like with my suggested initializer) then it is safe, no? I guess the main risk is if that requirement becomes unintentionally relaxed by another developer in the future?

If the use of a Sendable version of AnyHashable is required, are there any alternatives (outside of rolling my own AnyHashableSendable type) you'd suggest?

No, the point is that the conversion to AnyHashable is permitted to replace a Sendable type with a non-Sendable type. I bring this up specifically because AnyHashable does replace types: it makes sure that e.g. String and NSString are treated equivalently, even though String and NSString have different notions of equality and hashing.

Now, having this actually be a problem in practice is highly unlikely. And it would be reasonable for there to be a rule about bridged types that says both “sides” must be equally Sendable. But to my knowledge, such a rule does not exist today, and therefore I can’t promise that you won’t get into trouble.

2 Likes

I gotcha now. Thanks for reiterating that.

I missed this the first time through, but AnyHashable is explicitly marked as non-Sendable

Could you use any Hashable & Sendable instead of AnyHashable? You could still convert back at any API surfaces if you need to, but that way you still get full sendability checking from the compiler.

4 Likes

I was also going to suggest any Hashable & Sendable.

If you need a "normal" type that can pass through constrained generic code (i.e. <T: Hashable & Sendable>, which no existential can bind to because existentials never conform to protocols), then you'll need to write your own AnySendableHashable type eraser, but you can make it easy by reusing AnyHashable's implementations of the Hashable requirements.

Another strategy is to avoid existentials (either bare as any ... or manually type erased as Any...) completely by using generics. Make Foo a generic struct with a type parameter <Bar: Hashable & Sendable>, which will be the type of bar. In other words don't force the type of bar to be erased here.

Any code that uses Foo will then either know what concrete type it uses for bar and will just bind that to Bar, or it is similarly erased (getting chosen somewhere else) and becomes generic itself.

It you really need runtime dynamism (i.e. holding an array of Foos with different types of Bars), which is more rare than you might think, you can handle that with a protocol above Foo:

protocol FooProtocol<Bar>: Sendable {
  associatedtype Bar: Hashable & Sendable

  var bar: Bar { get }
}

Then wherever you truly need the type of Bar to be erased you can just use any FooProtocol, which automatically erases bar covariantly to any Hashable & Sendable.

A problem with this is if you recover the type of Bar, you get an any FooProtocol<Bar> but you probably want a Foo<Bar>. Force downcasting would be unfortunate, and this indicates the problem that anyone can write their own FooProtocol conformance when really you'd like to lock it down so only Foo conforms to FooProtocol. You can use a trick to both avoid the force downcast and make it practically impossible to write an "incorrect" conformance:

protocol FooProtocol<Bar>: Sendable {
  associatedtype Bar: Hashable & Sendable

  var bar: Bar { get }

  var asFoo: Foo<Bar> { get }
}

struct Foo<Bar: Hashable & Sendable>: FooProtocol {
  let bar: Bar

  var asFoo: Self { self }
}

Now you can turn an any FooProtocol<Bar> into a Foo<Bar> without a downcast that might fail, and anyone who tries conforming to FooProtocol will have to implement asFoo and be reminded they shouldn't be trying to.

But I would first try to just made code generic between where Foos are used and their bars are created to avoid existentials altogether (in short make the code strongly typed enough it doesn't need type erasure).

I’m going to go down the custom type eraser route. My example above was simplified, really I have a heterogenous typesafe collection that I need to be Sendable and Equatable. It only ever will hold low single digit elements.

protocol HKeyType {
  associatedtype Value: Hashable & Sendable
}

struct HeterogeneousCollection: Sendable, Hashable {
  private var storage: [ObjectIdentifier: AnyHashable] = [:]
  
  subscript<Key: HKeyType>(key: Key.Type) -> Key.Value? {
    get { storage[ObjectIdentifier(key)] as? Key.Value }
    set { storage[ObjectIdentifier(key)] = newValue }
  }
}

So the runtime dynamism is required.

I started down the path of using AnyHashable's implementation and adding a Sendable requirement as needed, but I'm not seeing the difference between that and simply wrapping AnyHashable in a private Sendable struct for use only in the getter/setter of HeterogeneousCollection

1 Like

If you wrap AnyHashable in a Sendable wrapper, won't that reintroduce the original problem, that AnyHashable isn't Sendable, so the wrapper can't be Sendable without bypassing compiler enforcements (like with @unchecked)?

That's why I figured you'd need to wrap any Hashable & Sendable instead, which isn't much different, you'd just need to wrap the private members in AnyHashable to implement ==.

This is what I landed on

protocol HKeyType {
  associatedtype Value: Hashable & Sendable
}

struct HeterogeneousCollection: Sendable {
  private var storage: [ObjectIdentifier: any (Hashable & Sendable)] = [:]
  
  subscript<Key: HKeyType>(key: Key.Type) -> Key.Value? {
    get { storage[ObjectIdentifier(key)] as? Key.Value }
    set { storage[ObjectIdentifier(key)] = newValue }
  }
}

extension HeterogeneousCollection: Equatable {
  public static func == (lhs: Self, rhs: Self) -> Bool {
    let (lhs, rhs) = (lhs.storage, rhs.storage)
    guard lhs.keys == rhs.keys else {
      return false
    }

    for key in lhs.keys {
      guard let lhs = lhs[key], let rhs = rhs[key] else {
        return false
      }

      let boxedLhs = AnyHashable(lhs)
      let boxedRhs = AnyHashable(rhs)
      guard boxedLhs == boxedRhs else {
        return false
      }
    }

    return true
  }
}

extension HeterogeneousCollection: Hashable {
  public func hash(into hasher: inout Hasher) {
    // From Dictionary's Hashable implementation 
    var commutativeHash = 0
    for (k, v) in storage {
      var elementHasher = hasher
      elementHasher.combine(k)
      elementHasher.combine(v)
      commutativeHash ^= elementHasher.finalize()
    }
    hasher.combine(commutativeHash)
  }
}

If you make the wrapper you don't have to re-implement equality or hashes for dictionaries:

struct AnySendableHashable: Hashable, Sendable {
  static func == (lhs: Self, rhs: Self) -> Bool {
    AnyHashable(lhs.base) == AnyHashable(rhs.base)
  }

  func hash(into hasher: inout Hasher) {
    base.hash(into: &hasher)
  }

  let base: any Hashable & Sendable
}

protocol HKeyType {
  associatedtype Value: Hashable & Sendable
}

struct HeterogeneousCollection: Hashable, Sendable {
  private var storage: [ObjectIdentifier: AnySendableHashable] = [:]

  subscript<Key: HKeyType>(key: Key.Type) -> Key.Value? {
    get { storage[.init(key)]?.base as! Key.Value? }
    set { storage[.init(key)] = .init(base: newValue) } 
  }
}

Since the value type of the storage dictionary is now Hashable, the dictionary itself is too, and you get the free implementations of == and hash.

Also notice that I force downcasted to an optional correctly typed value, instead of conditionally downcasting to a correctly typed value (as! Key.Value? instead of as? Key.Value), because the value stored for a Key should either be absent or have a type of Key.Value.

1 Like