Swift has been criticized more or less since its birth. New programming languages are not perfect, but which programming language is perfect? OC language can be considered as an ancient object-oriented language, its version is still 2.0, but Apple added many features to make it look stronger, such as Block, InstanceType and so on, but its core syntax has not changed much.

So far, Swift version has iteration to 5. *, the ABI has stable, each iteration update, will always bring some beautiful design pattern practice, for example, in terms of how to design the API for developers to bring the comfortable and powerful enumeration, extension and agreement, etc., not only let the developers in the definition of function have a more clear understanding, And when it comes to building aN API, first impressions tend to be light, while still gradually revealing more functionality and underlying complexity as needed.

In this article, I will try to create some lightweight apis and how to use the power of API combinations to make functionality or systems more powerful.

Functionality versus ease of use

Usually, when we design aN API, we look for a relatively balanced way in the interaction between the data structure and the function function, and ultimately build an API that meets the functional requirements and keeps the data structure as simple as possible. However, if you make the apis too simple, they may not be flexible enough to have the potential to evolve functionality, whereas a design that is too complex will inevitably lead to a complex and disorganized development effort that can lead to frustration, confusion of logic, and an API that is difficult to use, which can lead to delays and even failure.

For example, the main function of an application is to apply different filters to the image selected by the user. The core of each filter is actually a combination of image transformation, and different transformation combinations form different filter effects. Suppose the ImageFilter structure is used as the definition of an ImageFilter, as follows:

struct ImageFilter {
    var name: String
    var icon: Icon
    var transforms: [ImageTransform]}Copy the code

ImageTransform is a unified entry point for image transformations, which can be performed by many different transformations, so it can be defined as a Protocol, which is then followed by various transform types that implement individual transformation operations:

protocol ImageTransform {
    func apply(to image: Image) throws -> Image
}

struct PortraitImageTransform: ImageTransform {
    var zoomMultiplier: Double
    
    func apply(to image: Image) throws -> Image{... }}struct GrayScaleImageTransform: ImageTransform {
    var brightnessLevel: BrightnessLevel
    
    func apply(to image: Image) throws -> Image{... }}Copy the code

The advantage of this design approach is that since each transformation is implemented as its own type, it is free to let each transformation type define its own attributes and parameters when used. For example, the GrayScaleImageTransform accepts the BrightnessLevel parameter to transform the image into a grayscale image.

You can then combine as many image transformation types as you want to create different types of filter effects. For example, a filter that gives an image a “dramatic” look through a series of transformations:

let dramaticFilter = ImageFilter(
    name: "Dramatic", icon: .drama, transforms: [
        PortraitImageTransform(zoomMultiplier: 2.1),
        ContrastBoostImageTransform(),
        GrayScaleImageTransform(brightnessLevel: .dark)
    ]
)
Copy the code

So far So Good. But looking back at the implementation of the above API, it is certain that the implementation of the above is only for the implementation of the function, there is no advantage in the ease of use of THE API, So how to optimize, to ensure the function, while improving the flexibility and ease of use of the API? In the above implementation, each image transformation is implemented as a separate type, so there is no place to see all transformation types at a glance, and it is difficult for users to know which image transformation types are included in the code base.

In order to solve the problem of external users being unable to know the transformation types supported by the software library, suppose to use enumeration instead of the above method to see which method is more concise and easy to use.

enum ImageTransform {
    case protrait(_ zoomMultiplier: Double)
    case grayScale(_ brightnessLevel: BrightnessLevel)
    case contrastBoost
}
Copy the code

The benefit of using enumerations is to improve code cleanliness and readability, and to make the API more flexible to use, because enumerations allow developers to construct any number of transformations directly using point syntax, as follows:

let dramaticFilter = ImageFilter(
    name: "Dramatic",
    icon: .drama,
    transforms: [
        .protrait(2.1),
        .contrastBoost,
        .grayScale(.dark)
    ]
)
Copy the code

So far, enumerations have been a nice tool, and Swift’s enumerations type provides a good solution in many cases, but enumerations have their own obvious drawbacks.

Using enumerations in this case would have forced us to write a huge switch statement to handle each of these operations, which could have made the code tedious and so on, since each transformation would have required a completely different image operation.

Enumeration is light, but the structure is better

Fortunately, there is a third option — one that, for now, offers the best of both worlds. A structure, in contrast to a protocol or enumeration, is a data structure that can both define the type of operation and encapsulate closures for a given variety of operations. Such as:

struct ImageTransform {
    let closure: (Image) throws -> Image

    func apply(to image: Image) throws -> Image {
        try closure(image)
    }
}
Copy the code

The apply(to:) method should not be called externally here; it is written for code aesthetics and forward compatibility. In real project development, this can be distinguished by using macro definitions.

With that done, we can now create our transformations using static factory methods and properties — each transformation can still be individually defined and have its own set of parameters:

extension ImageTransform {
    static var contrastBoost: Self {
        ImageTransform { image in
            // ...}}static func portrait(_ multiplier: Double) -> Self {
        ImageTransform { image in
            // ...}}static func grayScale(_ brightness: BrightnessLevel) -> Self {
        ImageTransform { image in
            // ...}}}Copy the code

In Swift 5.1, Self can be used as the return type of a static factory method.

The advantage of the above approach is that we return to the flexibility and functionality of defining ImageTransform as a protocol, while still maintaining the same invocation as defined as enumerations – the dot syntax – for ease of use.

let dramaticFilter = ImageFilter(
    name: "Dramatic",
    icon: .drama,
    transforms: [
        .portrait(2.1),
        .contrastBoost,
        .grayScale(.dark)
    ]
)
Copy the code

The dot syntax itself has nothing to do with enumeration, but it can be used with any static API, which is very developer friendly. Using point syntax, we can construct the creation and modeling of the above filters as static properties, allowing us to further encapsulate properties, etc. Such as:

extension ImageFilter {
    static var dramatic: Self {
        ImageFilter(
            name:"Dramatic",
            icon: .drama,
            transforms: [
                .portrait(2.1),
                .contrastBoost,
                .grayScale(.dark)
            ]
        )
    }
}
Copy the code

With this modification, a series of complex tasks – including image filters and image transformations – are packaged into an API that can be used as easily as passing values to functions.

let filtered = image.withFilter(.dramatic)
Copy the code

The above series of modifications can be used to construct syntactic sugar for types. Not only does it improve the way the API is read, but it also improves the way the API is organized. Since all transformations and filters now only need to do flier 1 values, being able to organize in a variety of ways makes the API light and flexible in terms of extensibility, but also simple for users.

Variable parameters and API design

Let’s take a look at mutable parameters, another feature of the Swift language, and how they affect code construction in API design.

Suppose we’re developing an application that uses shape-based drawing to create its user interface, and we’ve used a structure-based approach similar to the one above to model each shape and eventually draw the result into DrawingContext:

struct Shape {
    var drawing: (inout DrawingContext) - >Void
}
Copy the code

The inout keyword is used above to enable the passing of DrawingContext.

Just as we easily created the ImageTransform using the static factory method in the example above, it is possible to encapsulate the drawing code for each shape in a completely separate method, as shown below:

extension Shape {
    func square(at point: Point, sideLength: Double) -> Self {
        Shape { context in
            let origin = point.movedBy(
                x: -sideLength / 2,
                y: -sideLength / 2
            )

            context.move(to: origin)
            context.drawLine(to: origin.movedBy(x: sideLength))
            context.drawLine(to: origin.movedBy(x: sideLength, y: sideLength))
            context.drawLine(to: origin.movedBy(y: sideLength))
            context.drawLine(to: origin)
        }
    }
}
Copy the code

Since each shape is simply modeled as a property value, drawing an array of them is very easy – all we need to do is create an instance of DrawingContext and pass it into the closure of each shape to build the final image:

func draw(_ shapes: [Shape]) -> Image {
    var context = DrawingContext()
    
    shapes.forEach { shape in
        context.move(to: .zero)
        shape.drawing(&context)
    }
    
    return context.makeImage()
}
Copy the code

Calling the above function also looks elegant, because once again we can use dot syntax to greatly reduce the amount of syntax required to perform our work:

let image = draw([
    .circle(at: point, radius: 10),
    .square(at: point, sideLength: 5)])Copy the code

But let’s see if we can take things a step further with mutable arguments. While not unique to Swift, using mutable parameters can lead to some very interesting results when combined with Swift’s truly flexible parameter naming capabilities.

When a parameter is marked as a mutable parameter (by adding… Suffix), we can basically pass any number of values to this parameter — the compiler automatically organizes them into an array for us, such as this:

func draw(_ shapes: Shape...) -> Image{...// Within our function, 'shapes' is still an array:shapes.forEach { ... }}Copy the code

With the above changes, we can now remove all array literals from calls to the draw function and make them look like this:

let image = draw(.circle(at: point, radius: 10),
                 .square(at: point, sideLength: 5))
Copy the code

This may not seem like a big change, but especially when designing lower-level apis designed to create more higher-level values (such as our DRAW function), using mutable parameters can make such apis feel lighter and more convenient.

However, one disadvantage of using mutable parameters is that a precomputed array of values can no longer be passed as a single parameter. Thankfully, this can be easily solved by creating a special group of shapes (like the draw function itself), iterating over a set of base shapes and drawing them:

extension Shape {
    static func group(_ shapes: [Shape]) -> Self {
        Shape { context in
            shapes.forEach { shape in
                context.move(to: .zero)
                shape.drawing(&context)
            }
        }
    }
}
Copy the code

With that done, we can now easily pass a set of precomputed Shape values to our draw function again, as follows:

let shapes: [Shape] = loadShapes()
let image = draw(.group(shapes))
Copy the code

What’s really cool, though, is that the group API above not only enables us to construct shapes arrays, but also makes it easier to combine multiple shapes into more advanced components. For example, this is how we can use a set of composite shapes to represent an entire figure (such as a logo) :

extension Shape {
    static func logo(withSize size: Size) -> Self {
        .group([
            .rectangle(at: size.centerPoint, size: size),
            .text("The Drawing Company", fittingInto: size), ... ] )}}Copy the code

Because the above logo is a Shape like the others, it can be drawn easily with a single call to the draw method, using the same elegant point syntax as before:

let logo = draw(.logo(withSize: size))
Copy the code

Interestingly, while our initial goal may have been to make our API more lightweight, doing so also made it more composable and flexible.

conclusion

The more tools we add to the API designer’s toolbox, the more likely we are to be able to design aN API that strikes the right balance between functionality, flexibility, and ease of use. Making apis as light as possible may not be our ultimate goal, but by minimizing the number of apis, we often find out how to make them more powerful – by making them more flexible in the way we create types, and by making them more composed. All of this helps us achieve the perfect balance between simplicity and functionality.

Lightweight API design in Swift

Link: www.swiftbysundell.com/articles/li…