Skip to content

Instantly share code, notes, and snippets.

@erica
Created January 24, 2019 21:31
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save erica/f165c700dca5150a3ff7e7e73bc910d3 to your computer and use it in GitHub Desktop.
Save erica/f165c700dca5150a3ff7e7e73bc910d3 to your computer and use it in GitHub Desktop.

Introducing Namespacing for Common Swift Error Scenarios

Introduction

This proposal introduces a namespaced umbrella for common exit scenarios. In doing so, it modernizes fatalError, a holdover from C-like languages.

Discussion: Introducing Namespacing for Common Swift Error Scenarios

Motivation

Swift's fatalError is a one-size-fits-all hammer. Swift lacks a modern extensible solution that differentiates fatal error scenarios indicating the specific contexts under which the error occurred. Because of this, many developer libraries now implement custom exit points, like the one shown in the following example, to provide that functionality:

/// Handles unimplemented functionality with site-specific information
/// courtesy of Soroush Khanlou, who discovered it from Caleb Davenport, 
/// who discovered it from Brian King
func unimplemented(_ function: String = #function, _ file: String = #file) -> Never {
    fatalError("\(function) in \(file) has not been implemented")
}

Namespacing these marker functions to a common type results in groupable, extensible, and discoverable IDE elements.

Universal Error States

There are four universal core language concepts that can be better represented in Swift. They are:

  • This code is unreachable.
  • This code is not yet implemented.
  • This method must be overriden in a subtype.
  • This code should never be called.

Each item represents a fundamental language design scenario.

Unreachable

The Swift compiler cannot detect that the following code is exhaustive. It emits an error and recommends a default clause:

switch Int.random(in: 0...2) {
case 0: print("got 0")
case 1: print("got 1")
case 2: print("got 2")
}

// error: switch must be exhaustive
// note: do you want to add a default clause?

The default clause should annotate the code, making it clear that the clause is unreachable and should never run. If it does run, a serious breach in logic has occurred. It should result in a fatal outcome.

Not yet implemented

In Swift, TODO items have not yet been implemented or completed. An unimplemented type member may be reachable but calling it should trap at runtime because the logic has not yet been implemented:

public func semanticallyResonantMethodName() {
    // TODO: implement this
}

This call should establish why the code cannot yet be run. Swift's compile-time diagnostics are insufficient as they don't provide runtime suport:

  • Using #error prevents compilation and debugging.
  • Using #warning is insufficient. It can be missed at compile time and error in "warn-as-errors" development houses.

Swift should provide a way to trap at run-time to indicate an currently-unsafe avenue of execution.

Must override

An abstract supertype may require methods and properties to be implemented in concrete subtypes:

class AbstractSupertype {
    func someKeyBehavior() {
        // Must override in subclass
    }
}

This method should never be called, as the type is abstract. Again, this should trap at runtime to indicate the need for a subclass override.

Must not be called

Silencing warnings must not contradict Swift safety best practices. Some required members may add functionality that should never be called:

required init?(coder: NSCoder) {
  super.init(coder: coder)
  // Xcode requires unimplemented initializer
}

A method that is left deliberately unimplemented rather than as a TODO item should be annotated as such and produce an appropriate runtime experience when accessed in code.

Design

Overload the global fatalError function as fatalError(because:) and introduce FatalReason, a new type that vends static members to error under these universal scenarios. The precise wording and choice of each member and error message can be bikeshedded after acceptance of this proposal's concept.

import Foundation

extension Never {
  /// Reasons why code should die at runtime
  public struct FatalReason: CustomStringConvertible {
    /// Die because this code branch should be unreachable.
    public static let unreachable = FatalReason("This code should never be reached during execution.")
    
    /// Die because this method or function has not yet been implemented.
    public static let notYetImplemented = FatalReason("This functionality is not yet implemented.")

    /// Die because a default method must be overriden by a subtype 
    /// or extension.
    public static let subtypeMustOverride = FatalReason("This type member must be overriden in subtype.")
    
    /// Die because this functionality should never be called,
    /// typically to silence requirements.
    public static let mustNotBeCalled = FatalReason("This functionality should never be called.")
    
    /// An underlying string-based cause for a fatal error.
    public let reason: String
    
    /// Establishes a new instance of a `FatalReason` with a string-based explanation.
    public init(_ reason: String) {
      self.reason = reason
    }
    
    /// Conforms to CustomStringConvertible, allowing reason to
    /// print directly to complaint.
    public var description: String {
      return reason
    }
  }
}

/// Unconditionally prints a given message and stops execution.
///
/// - Parameters:
///   - reason: A predefined `FatalReason`.
///   - function: The name of the calling function to print with `message`. The
///     default is the calling scope where `fatalError(because:, function:, file:, line:)`
///     is called.
///   - file: The file name to print with `message`. The default is the file
///     where `fatalError(because:, function:, file:, line:)` is called.
///   - line: The line number to print along with `message`. The default is the
///     line number where `fatalError(because:, function:, file:, line:)` is called.
@inlinable // FIXME(sil-serialize-all)
@_transparent
public func fatalError(
  because reason: Never.FatalReason,
  function: StaticString = #function,
  file: StaticString = #file,
  line: UInt = #line
  ) -> Never {
  fatalError("\(function): \(reason)", file: file, line: line)
}

You call fatalError(because:) using the static entry points:

fatalError(because: .unreachable)
fatalError(because: .notYetImplemented)
fatalError(because: .subtypeMustOverride)
fatalError(because: .mustNotBeCalled)

A few points about this design:

  • The text is intentionally developer-facing and unlocalized, as with other warning and error content.
  • The contextual members (function, file, and line) are automatically picked up by the defaulted properties and passed to fatalError.
  • The FatalReason namespacing allows each reason to be auto-completed by a hosting IDE.
  • The FatalReason namespace encourages additions specific to fatal outcomes. As its name suggests, it is not a shared repository for developer-facing strings.
  • The FatalReason namespace is parented within Never as a logical and thematically fitting location.

This design allows you to extend FatalReason to introduce custom causes of programmatic run-time failure common to your in-house work, such as:

// Custom outlet complaint
extension Never.FatalReason {
  static func missingOutlet(_ name: String) -> FatalReason {
    return FatalReason("Outlet \"\(name)\" is not connected")
  }
}

// for example:
fatalError(because: .missingOutlet("myCheckbox"))

Source compatibility

This proposal is strictly additive. There is no reason to deprecate or remove fatalError.

Effect on ABI stability and API resiliance

None

Alternatives considered

  • Introduce a standalone type (such as Abort, Crash, Fatal or Trap) and a static method to avoid using a global function. This approach is less intuitive and breaks the mold established by the current fatalError function.

  • Reconfigure the fatalError callsite to drop an external label, allowing fatalError("some string") and fatalError(.someReason).

  • Extend Never directly instead of introducing a new subtype. Never.because() and Never.reason() are confusing to read. Never.die() is contradictory.

Acknowlegements

Thanks Ian Keen, Brent Royal-Gordon, Lance Parker, Lily Vulcano.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment