Skip to main content
deleted 37 characters in body
Source Link
kaya3
  • 20.8k
  • 46
  • 126

flatten1 is O(n2)$O(n^2)$. Consider the implementations of the operators + and +=, which could look something like this at a high level:

flatten1 is O(n2). Consider the implementations of the operators + and +=, which could look something like this at a high level:

flatten1 is $O(n^2)$. Consider the implementations of the operators + and +=, which could look something like this at a high level:

edited title
Link
kaya3
  • 20.8k
  • 46
  • 126

How to avoid hidden footgunsperformance problems in functional interfaces?

Source Link
Bbrk24
  • 9.4k
  • 1
  • 18
  • 68

How to avoid hidden footguns in functional interfaces?

Swift has a protocol Sequence<Element>, which is used to support for-in loops. It has a bunch of extension methods to support functional programming style, such as map and filter. One pair of these methods is called "reduce", which are implemented roughly as follows:

extension Sequence {
  func reduce<T>(_ start: T, _ combine: (T, Element) throws -> T) rethrows -> T {
    var result = start
    for el in self {
      result = try combine(result, el)
    }
    return result
  }

  func reduce<T>(into start: T, _ combine: (inout T, Element) throws -> Void) rethrows -> T {
    var result = start
    for el in self {
      try combine(&result, el)
    }
    return result
  }
}

These seem like fairly innocuous and useful methods. However, there's one pitfall that people tend not to be aware of. Consider using these to implement a "flatten" method. Either one could be used:

func flatten1<T>(_ arr: [[T]]) -> [T] {
  arr.reduce([], +)
}

func flatten2<T>(_ arr: [[T]]) -> [T] {
  arr.reduce(into: [], +=)
}

However, there's a hidden performance issue. Do you see it?

flatten1 is O(n2). Consider the implementations of the operators + and +=, which could look something like this at a high level:

func + <T>(lhs: [T], rhs: [T]) -> [T] {
  let destBuffer = calloc(lhs.count + rhs.count, MemoryLayout<T>.stride)!
  memcpy(destBuffer, lhs.buffer, lhs.count * MemoryLayout<T>.stride)
  memcpy(destBuffer + lhs.count, rhs.buffer, rhs.count * MemoryLayout<T>.stride)
  return makeArray(buffer: destBuffer, count: lhs.count + rhs.count)
}

func += <T>(lhs: inout [T], rhs: [T]) {
  if lhs.capacity < lhs.count + rhs.count {
    lhs.capacity = max(lhs.count + rhs.count, lhs.capacity * 2)
    lhs.buffer = realloc(lhs.buffer, lhs.capacity * MemoryLayout<T>.stride)!
  }
  memmove(lhs.buffer + lhs.count, rhs.buffer, rhs.count * MemoryLayout<T>.stride)
  lhs.count += rhs.count
}

Every call to + must allocate a new buffer and copy both of its operands in, while calls to += usually don't have to. The optimizer is allowed to elide the unnecessary copies, but it is not required to. Swift 5.8 doesn't, even when bounds checking is disabled.

Every time I mention this, someone is appalled that this footgun exists in the language. However, I can't think of any obvious ways to avoid this. So, my question is: How could Swift have designed these methods better to avoid performance traps?