Tuples, Pointers and C interop

Recently, got caught by a problem with my code when using tuples with withUnsafePointer(to:. Want to highlight this and see some feedback from the community.

TD;LR: This is surprising because you either get incorrect pointer AND correct type or you get correct pointer AND incorrect type.

I know we shouldn't assume any pointer stability when using tuples in Swift. However, when interop with C libraries, they often have structs that contain static arrays. These static arrays will be interop'ed to tuples in Swift. Their C functions will accept the pointer form of these static arrays, making it impossible to avoid withUnsafePointer(to: family of methods.

However, with these methods, you end up in improbable case where if you want to have the correct type, you will have the incorrect pointer. If you get the pointer correctly, the type is incorrect and requires a cast. Here is the example:

var aCStructWithATuple = ...
aCStructWithATuple.tuple.0 = 10
aCStructWithATuple.tuple.1 = 11
aCStructWithATuple.tuple.2 = 12
let incorrectTuple2 = withUnsafePointer(to: &aCStructWithATuple.tuple.0) { $0[2] }
// This will show random number because $0 is not what you think it is.
let correctTuple2 = withUnsafePointer(to: &aCStructWithATuple.tuple) { UnsafeRawPointer($0).assumingMemoryBound(to: Int32.self)[2] }
// This will show 12, but without cast, the type will be UnsafePointer<(Int32, Int32, Int32, Int32)>

I have a unit test case in swift-mujoco/tuple.swift at main · liuliu/swift-mujoco · GitHub It is pretty easy to modify the test case to show the behavior.

Do you have a better way to handle this? Can I get the correct pointer and the correct type somehow in some way?

1 Like

When you write:

let incorrectTuple2 = withUnsafePointer(to: &aCStructWithATuple.tuple.0) { $0[2] }

You are accessing outside the bounds of the pointer, so it is undefined behaviour. In fact, I think this pattern is always UB, even for non-tuple values:

// Always UB. For any type.
withUnsafePointer(to: &x) { $0[2] }

If you want a pointer which covers all elements of the tuple, you must pass the entire tuple as the argument. Indeed, that means you will need to cast the pointer - and that is safe, but only if the tuple is homogenous (all elements have the same type). A pointer to a homogenous tuple is already bound to its element type, so it is appropriate to use assumingMemoryBound (as you have done).

This is something we really do need to improve; probably with some sort of real fixed-size arrays. For now, the best I can suggest is to copy and paste a bunch of helper functions for the tuple sizes you need.

// Arity 8:

@inlinable @inline(__always)
internal func withUnsafeBufferPointerToElements<T, Result>(
  tuple: (T, T, T, T, T, T, T, T), _ body: (UnsafeBufferPointer<T>) -> Result
) -> Result {
  return withUnsafeBytes(of: tuple) {
    let ptr = UnsafeBufferPointer(start: $0.baseAddress!.assumingMemoryBound(to: T.self), count: 8)
    return body(ptr)
  }
}

@inlinable @inline(__always)
internal func withUnsafeMutableBufferPointerToElements<T, Result>(
  tuple: inout (T, T, T, T, T, T, T, T), _ body: (inout UnsafeMutableBufferPointer<T>) -> Result
) -> Result {
  return withUnsafeMutableBytes(of: &tuple) {
    var ptr = UnsafeMutableBufferPointer(start: $0.baseAddress!.assumingMemoryBound(to: T.self), count: 8)
    return body(&ptr)
  }
}
6 Likes

In this particular case I believe the key-path accessor for pointers would also work:

withUnsafePointer(to: &tuple) {
  $0.pointer(to: \.0)![2]
}

Here you are relying on the fact that members of tuples stored in memory always have (EDIT: sequential) addresses, something that is exceedingly unlikely to break (and part of the stable ABI on Apple platforms).

The downside of this compared to Karl’s solution is that it’s not a buffer pointer, so it’s not bounds-checked even in debug builds.

3 Likes