Как еще использовать type safety в целях улучшения API

в 16:08, , рубрики: cocos2d, Fiber2D, swift, swift 3, type safety, математика, приёмы, разработка под iOS, Разработка под OS X, строгая типизация, метки:

Всем привет! Я — lead developer cocos2d-objc. Сейчас мы находимся в процессе портирования на Swift. Я планирую освещать процесс разработки, рассказывать архитектурные решения и т.д. Пока что проект еще на proof-of-a-concept стадии, поэтому сегодня я расскажу только о маленьком приёме, который, как я считаю, сделал нашу математическую библиотеку чуть лучше. Если интересно — прошу под кат.
image

Перед тем, как начать переписывать движок на Swift, стала очевидной потребность в современной библиотеке под математические нужды. Движок изначально x-platform in mind, так что CG* типы мы использовать не могли, да и CoreGraphics API недостаточно Swifty для нас. Существующие решения нас не удовлетворяли, поэтому мы решили написать свой велосипед, при этом придерживаясь определенного аскетизма.

Мы ограничились скромным набором типов: Vector2f, Vector3f, Vector4f, Matrix3f, Matrix4f, Rect. Мы твердо решили, что мы хотим полностью исключить ARC overhead и точно хотим иметь поддержку SIMD (хотя бы на Darwin платформах, для Glibc пока что алгоритмы прописаны вручную, до тех пор, пока simd не будет доступен публично), по этой причине пришлось отказаться от дженериков и завязать всю библиотеку на типе Float, без поддержки Double.

Ок, я понял, что вам это не интересно, это вступление. О чем статья?

В какой-то момент мы поняли, что API, которое работает с углами (вроде методов создания матрицы поворота и т.д.) нуждается в усовершенствовании. Проблема, которая казалась нам важной — убрать необходимость пользователям смотреть документацию на предмет того, что ожидает получить метод: радианы или градусы.

Изначально мы хотели сделать алиасы на Float вида

typealias Radians = Float; typealias Degrees = Float;

Понятное дело, что это особо не спасает, т.к. все равно есть вероятность передать в метод значение не в той величине.

Также мы рассматривали создание функций rad() и deg(), которые бы возвращали нужное значение. Варианты расширений Int и Float, а также их литералов вида

45.degrees
180.radians

Нам не нравились, т.к. позволяли делать:

180.radians.radians

В итоге было принято решение создать отдельную структуру под тип Angle:

/// A floating point value that represents an angle
public struct Angle {
	
    /// The value of the angle in degrees
    public let degrees: Float
	
    /// The value of the angle in radians
    public var radians: Float {
        return degrees * Float.pi / 180.0
    }

	/// Creates an instance using the value in radians
    @inline(__always)
    public init(radians val: Float) {
        degrees = val / Float.pi * 180.0
    }
	
	/// Creates an instance using the value in degrees
    @inline(__always)
    public init(degrees val: Float) {
        degrees = val
    }
    
    @inline(__always)
    internal init(_ val: Float) {
        degrees = val
    }
}

Чем это хорошо? Теперь мы можем сказать пользователю, что метод ожидает угол в качестве параметра, причем ему не нужно беспокоиться, в каком именно виде: в радианах или в градусах.
Если он передает структуру Angle, то он уверен, что все будет работать корректно.

Мы определили все стандартные операторы для работы с Angle (такие же, как для скалярных величин, только Angle / Angle возвращает Float вместо Angle, а Angle * Angle нет вовсе)

Также мы решили оставить extension для Int:

extension Int {
    /// Returns the integer value as an angle in degrees
    public var degrees: Angle {
        return Angle(degrees: Float(self))
    }
}

Таким образом, мы оперируем нашими углами в градусах, не теряя точность там, где не надо и переводим их в радианы только тогда, когда необходимо (обычно, при конечных вычислениях).
Чтобы еще больше обеспечить точность, мы определили sin и cos для Angle вот так:

@inline(__always)
internal func sinf(_ a: Angle) -> Float {
	return __sinpif(a.degrees / 180.0)
}

@inline(__always)
internal func cosf(_ a: Angle) -> Float {
	return __cospif(a.degrees / 180.0)
}

Правда, для Glibc пришлось написать обычную имплементацию этих фукнций, т.к. там нет функций повышенной точности.

Ну и напоследок: использование юникода в коде — всегда спорная тема. Лично я совсем не приветствую это. Изначально, мы ради веселья добавили вот такой оператор:


/// The degree operator constructs an `Angle` from the specified floating point value in degrees
///
/// - remark: 
/// * Degree operator is the unicode symbol U+00B0 DEGREE SIGN
/// * macOS shortcut is ⌘+⇧+8
@inline(__always)
public postfix func °(lhs: Float) -> Angle {
    return Angle(degrees: lhs)
}

/// Constructs an `Angle` from the specified `Int` value in degrees
@inline(__always)
public postfix func °(lhs: Int) -> Angle {
    return Angle(degrees: Float(lhs))
}

И определили наши константы вот так:

    // MARK: Constants
    public static let zero  = 0°
    public static let pi_6  = 30°
    public static let pi_4  = 45°
    public static let pi_3  = 60°
    public static let pi_2  = 90°
    public static let pi2_3 = 120°
    public static let pi    = 180°
    public static let pi3_2 = 270°
    public static let pi2   = 360°

В итоге, я поймал себя на том, что использовал этот оператор в коде самого движка, читабельности это добавляет, однако не стоит злоупотреблять — не все помнят шорткаты и порой это бесит.

Статья получилась слишком большой, для достаточно пустякового решения, однако я надеюсь вам было интересно. Применяя этот прием, мы решили проблему, когда нужно гонять туда-сюда значения и читать документацию на предмет того, в каких величинах ожидается значение.

Следить за портированием движка можно тут.
Ссылка на math lib.

Автор: s1dd0k

Источник

Поделиться новостью

* - обязательные к заполнению поля