Upgrade to Pro — share decks privately, control downloads, hide ads and more …

So You're Writing a Framework... (more Swift version)

Bas Broek
December 10, 2018

So You're Writing a Framework... (more Swift version)

How to create better, nicer APIs.

Bas Broek

December 10, 2018
Tweet

More Decks by Bas Broek

Other Decks in Programming

Transcript

  1. So You're
    Writing a
    Framework...
    1 — @basthomas

    View Slide

  2. So You're
    Writing a
    Framework...
    2 — @basthomas

    View Slide

  3. So You're
    Writing a
    Feature...
    3 — @basthomas

    View Slide

  4. So You're
    Writing an
    API...
    4 — @basthomas

    View Slide

  5. So You're
    Writing a
    Library...
    5 — @basthomas

    View Slide

  6. So You're
    Writing a
    Code Base...
    6 — @basthomas

    View Slide

  7. So You're
    Writing an
    App...
    7 — @basthomas

    View Slide

  8. Who am I
    → iOS Developer @ XING
    → I curate Swift Weekly Brief
    → I do some open source things
    → I like continuous integration systems, too
    8 — @basthomas

    View Slide

  9. Have you ever tried...
    → using UIKit without documentation? (Xcode
    "helps" sometimes)
    → using someone's (undocumented) API without
    looking at the implementation?
    → using an undocumented API you wrote in the
    past, without looking at the
    implementation?
    9 — @basthomas

    View Slide

  10. Looking into a pull
    request that came up in
    my GitHub notifications...
    10 — @basthomas

    View Slide

  11. [WIP] Add lists class
    No description provided
    11 — @basthomas

    View Slide

  12. class Lists {
    let array1: [String]
    let array2: [String]
    init(array1: [String], array2: [String]) {
    self.array1 = array1
    self.array2 = array2
    }
    func combineOutputs() -> [String] {
    return concatArrays()
    }
    private var occurences: Int {
    // https://github.com/apple/swift-evolution/blob/master/proposals/0220-count-where.md
    return array1.filter({ $0 == array2.first }).count
    }
    private func occurredAtIndex(array: [String]) -> Int? {
    guard let first = array2.first else { return nil }
    array.firstIndex(of: first)
    }
    private func occurrenceChunks() -> [String] {
    var chunks: [String] = []
    var array = array1
    for _ in 0...occurences {
    guard let occurrenceIndex = occurredAtIndex(array) else { continue }
    chunks.append(array[occurrenceIndex...array.count])
    array.remove(at: occurrenceIndex)
    }
    return chunks
    }
    // more of this
    }
    12 — @basthomas

    View Slide

  13. Does anyone have any
    idea what's going on?
    13 — @basthomas

    View Slide

  14. Me neither.
    14 — @basthomas

    View Slide

  15. 15 — @basthomas

    View Slide

  16. This pull request adds a possibility to
    concat arrays without removing the
    duplicates in the parts that are not
    overlap completely (in the meaning that
    we care only if the last x values of 1st
    array overlaps with the same x values of
    the 2nd, but we don't care if some
    duplicates are randomly exist in the body
    of array)
    16 — @basthomas

    View Slide

  17. This pull request adds a
    possibility to concat arrays
    without removing the
    duplicates in the parts that
    are not overlap completely
    17 — @basthomas

    View Slide

  18. /// - Parameter array1: an array that contain all the string collected till
    /// this moment
    /// - Parameter array2: an array that contain new elements that should be
    /// added in array1
    init(array1: [String], array2: [String]) {
    self.array1 = array1
    self.array2 = array2
    }
    /// Combines two arrays with partial removal of duplicates
    ///
    /// - Returns: An array of complate `array1` and partially (removing the first
    /// occurances in both arrays) added `array2`
    func combineOutputs() -> [String] {
    return concatArrays()
    }
    18 — @basthomas

    View Slide

  19. class ListsTestCase: XCTestCase {
    func testCombineOutputs() {
    let array1 = ["a", "b", "c"]
    let array2 = ["c", "d", "e"]
    let expected = ["a", "b", "c", "d", "e"]
    XCTAssertEqual(Lists(array1, array2).combineOutputs(), expected)
    let array3 = ["x", "y", "z"]
    // Could not find comparable data.
    XCTAssertThrows(try Lists(array1, array3).combineOutputs())
    }
    }
    19 — @basthomas

    View Slide

  20. class Lists {
    enum ListError: Error {
    case noOverlap
    }
    /// Adds two arrays with removing the overlap between them
    ///
    /// - Parameter existing: the exsting elemets.
    /// - Parameter newEntries: the elements that should be added to the front
    /// - Returns: the sum of both array with `newEntries` at the front
    static func concatenateWithoutOverlap(
    existing: [String],
    newEntries: [String]
    ) throws -> [String] {
    let occourences = newEntries.enumerated().filter { index, entry in
    newEntries[index] == existing.first
    }
    let _overlapStart = occourences.first(where: { index in
    existing.starts(with: newEntries[index..<-1])
    })
    guard let overlapStart = _overlapStart else { throw ListError.noOverlap }
    return [Array(arr3.prefix(through: 1)), arr2].flatMap { $0 }
    }
    }
    20 — @basthomas

    View Slide

  21. Naming is
    hard.
    21 — @basthomas

    View Slide

  22. class Lists {
    enum ListError: Error {
    case noOverlap
    }
    /// Adds two arrays with removing the overlap between them
    ///
    /// - Parameter existing: the exsting elemets.
    /// - Parameter newEntries: the elements that should be added to the front
    /// - Returns: the sum of both array with `newEntries` at the front
    static func concatenateWithoutOverlap(
    existing: [String],
    newEntries: [String]
    ) throws -> [String] {
    // implementation
    }
    }
    22 — @basthomas

    View Slide

  23. /// Prepends an array to front of an existing one,
    /// without adding the overlap twice.
    ///
    /// Example:
    ///
    /// ```swift
    /// let existing = [4, 3, 2, 1]
    /// let newEntries = [6, 5, 4, 3]
    /// Lists.concatenateWithoutOverlap(
    /// existing: existing,
    /// newEntries: newEntries
    /// ) // Returns [6, 5, 4, 3, 2, 1]
    /// ```
    ///
    /// - Parameter existing: the existing array.
    /// - Parameter newEntries: the array that should be added to the front
    /// - Returns: returns the new array
    23 — @basthomas

    View Slide

  24. class Lists {
    enum ListError: Error {
    case noOverlap
    }
    /// Prepends an array to front of an existing one,
    /// without adding the overlap twice.
    ///
    /// Example:
    ///
    /// ```swift
    /// let existing = [4, 3, 2, 1]
    /// let newEntries = [6, 5, 4, 3]
    /// Lists.concatenateWithoutOverlap(
    /// existing: existing,
    /// newEntries: newEntries
    /// ) // Returns [6, 5, 4, 3, 2, 1]
    /// ```
    ///
    /// - Parameter existing: the existing array.
    /// - Parameter newEntries: the array that should be added to the front
    /// - Returns: returns the new array
    static func stitch(
    existing: [String],
    with newEntries: [String]
    ) throws -> [String] {
    // Get the index of all occurrences of the first element from the
    // `newEntries` array in `existing`
    let occurrences = newEntries.enumerated().filter { index, entry in
    newEntries[index] == existing.first
    }
    // Check if one of the slices of `existing` is prefix of
    // the `newEntries` array
    let _overlapStart = occurrences.first(where: { index in
    existing.starts(with: newEntries[index..<-1])
    })
    guard let overlapStart = _overlapStart else { throw ListError.noOverlap }
    // Add the `newEntries` without the overlap at the end of the existing array
    return [Array(arr3.prefix(through: 1)), arr2].flatMap { $0 }
    }
    }
    24 — @basthomas

    View Slide

  25. !!!
    25 — @basthomas

    View Slide

  26. Code
    26 — @basthomas

    View Slide

  27. Tests
    27 — @basthomas

    View Slide

  28. Documentation
    28 — @basthomas

    View Slide

  29. Code.
    29 — @basthomas

    View Slide

  30. Tests.
    30 — @basthomas

    View Slide

  31. Documentation.
    31 — @basthomas

    View Slide

  32. Code.
    Tests.
    Documentation.
    32 — @basthomas

    View Slide

  33. These are not three
    separate devices!
    — Steve Jobs, at some event
    33 — @basthomas

    View Slide

  34. They are like
    a Venn
    diagram
    34 — @basthomas

    View Slide


  35. Warning, potential unpopular opinion ahead
    35 — @basthomas

    View Slide

  36. The order
    does not
    matter*
    * But do figure out what works best for your team.
    There's a high chance starting with tests is a good idea.
    36 — @basthomas

    View Slide

  37. How to
    measure
    success?
    37 — @basthomas

    View Slide

  38. It is successful if a failure
    or success case that is
    discovered or added in
    the future can be solved via
    Test Driven Development
    — Me
    38 — @basthomas

    View Slide

  39. Communication is hard.
    39 — @basthomas

    View Slide

  40. Managing expectations.
    40 — @basthomas

    View Slide

  41. What more can we do?
    → Give examples
    → Write simple, non near-code documentation
    → "No raw loops"
    → Self-enforced best practice
    → Strongly typed APIs
    → Scope
    → Compassion
    → ..?
    41 — @basthomas

    View Slide

  42. OK, not yet...
    → Component System
    → Component (Text) Configuration
    → Strong typing
    → Objective-C interop...
    → (is a good thing?)
    42 — @basthomas

    View Slide

  43. @objc public class TextConfiguration: NSObject {
    @objc public init(
    color: TextColor,
    weight: TextWeight,
    size: TextSize,
    numberOfLines: Int
    ) {
    self._color = color
    self.weight = weight
    self.size = size
    self.numberOfLines = numberOfLines
    }
    }
    43 — @basthomas

    View Slide

  44. /// Creates a new `TextConfiguration` with the specified values.
    ///
    /// - Parameter color: the text color to use. Available colors are a subset
    /// of `XINGColor` and are safe to use for text.
    /// - Parameter weight: the text weight to use. This will be used for the text's font.
    /// - Parameter size: the text size to use. This will be used for the text's font.
    /// - Parameter numberOfLines: The number of lines of text to render.
    /// To remove any maximum limit, and use as many lines as needed,
    /// set the value of this property to 0.
    @objc public init(
    color: TextColor,
    weight: TextWeight,
    size: TextSize,
    numberOfLines: Int
    ) {
    self._color = color
    self.weight = weight
    self.size = size
    self.numberOfLines = numberOfLines
    }
    44 — @basthomas

    View Slide

  45. let headerConfiguration = TextConfiguration(
    color: .blue500,
    weight: .medium,
    size: .default12
    )
    // now what?
    45 — @basthomas

    View Slide

  46. /// - Paramter label: the label to generate a text configuration from.
    ///
    /// - Returns: a `TextConfiguration` based on the label, if it uses valid
    /// text configuration values. If not, it will assert and revert to default options.
    @objc public static func from(label: UILabel) -> TextConfiguration {
    func textColor(from color: UIColor) -> TextColor {
    guard let _textColor = TextColor.all.first(where: { $0.uiColor == label.textColor }) else {
    assertionFailure("Could not convert label's color to a valid TextColor. This will default to `.grey800` in production, which may be unexpected.")
    return .grey800
    }
    return _textColor
    }
    func textWeight(from font: UIFont) -> TextWeight {
    guard let _textStyle = TextWeight.from(font: font) else {
    assertionFailure("Could not convert font's weight to a valid TextWeight. This will default to `.regular` in production, which may be unexpected.")
    return .regular
    }
    return _textStyle
    }
    func textSize(from font: UIFont) -> TextSize {
    guard let _textSize = TextSize.from(font: font) else {
    assertionFailure("Could not convert font's size to a valid TextSize. This will default to `.default12` in production, which may be unexpected.")
    return .default12
    }
    return _textSize
    }
    return .init(
    color: textColor(from: label.textColor),
    weight: textWeight(from: label.font),
    size: textSize(from: label.font),
    numberOfLines: label.numberOfLines
    )
    }
    46 — @basthomas

    View Slide

  47. func textColor(from color: UIColor) -> TextColor {
    guard let _textColor = TextColor.all.first(where: { $0.uiColor == label.textColor }) else {
    assertionFailure("Could not convert label's color to a valid TextColor.")
    return .grey800
    }
    return _textColor
    }
    func textWeight(from font: UIFont) -> TextWeight {
    guard let _textStyle = TextWeight.from(font: font) else {
    assertionFailure("Could not convert font's weight to a valid TextWeight.")
    return .regular
    }
    return _textStyle
    }
    func textSize(from font: UIFont) -> TextSize {
    guard let _textSize = TextSize.from(font: font) else {
    assertionFailure("Could not convert font's size to a valid TextSize.")
    return .default12
    }
    return _textSize
    }
    47 — @basthomas

    View Slide

  48. @objc public static func from(label: UILabel) -> TextConfiguration {
    return .init(
    color: textColor(from: label.textColor),
    weight: textWeight(from: label.font),
    size: textSize(from: label.font),
    numberOfLines: label.numberOfLines
    )
    }
    48 — @basthomas

    View Slide

  49. /// Applies the current `TextConfiguration` to the supplied label.
    ///
    /// - Parameter label: the `UILabel` instance to apply the
    /// text configuration to.
    ///
    /// - Returns: a reference to the label that has the configuration
    /// aplied to it.
    @discardableResult
    @objc public func apply(to label: UILabel) -> UILabel {
    label.textColor = color
    label.font = font
    label.numberOfLines = numberOfLines
    return label
    }
    49 — @basthomas

    View Slide

  50. let headerConfiguration = TextConfiguration.from(label: headerLabel)
    let subtitleLabel = UILabel()
    headerConfiguration
    .with(color: .red500)
    .apply(to: subtitleLabel)
    50 — @basthomas

    View Slide

  51. Outcome
    → Scoped
    → Testable (and bugs and edge cases fixed /
    covered during development)
    → Objective-C interop
    → Chainable API
    → It's not perfect (and that is OK)
    → Architecture design is missing
    51 — @basthomas

    View Slide

  52. Outcome, part 2
    → The compiler is your friend
    → Don't be afraid to use types
    → The compiler is your enemy
    → We should explain the architectural design
    → Prevent "rest of the damn owl" syndrom
    → Use assertions!
    52 — @basthomas

    View Slide

  53. Things to keep in mind
    → I don't want to write the correct code. There is no
    correct code. There is no silver bullet.
    → I want to help the most correct code to be written.
    → I want to prevent incorrect code to be written.
    → I want to fail early and often.
    → (but for that, I need it to be testable)
    → Iterate, iterate, iterate
    53 — @basthomas

    View Slide

  54. Takeaways
    → Optimize code for reading, not writing
    → Work together and bounce off ideas
    → Documentation is helpful for yourself when writing
    code, too
    → Documentation helps you with naming, scope,
    testability and readability
    → Having testable code feels awesome
    → Snapshot tests are the root of all evil
    54 — @basthomas

    View Slide

  55. Thanks!
    @basthomas
    @_contravariance podcast
    @swiftlybrief newsletter
    55 — @basthomas

    View Slide

  56. Further reading
    "Combinators" by Daniel Steinberg
    https://vimeo.com/290272240
    "Denotational Design" by Conal Elliot
    https://github.com/conal/talk-2014-lambdajam-denotational-design
    "... But That Should Work?" by me
    https://basthomas.github.io/but-that-should-work
    Point-Free by Brandon Williams & Stephen Celis
    https://www.pointfree.co
    56 — @basthomas

    View Slide