Saturday , April 17 2021

Lightweight API Design in Swift, Hacker News

One of the most powerful aspects of Swift is just how much flexibility it gives us when it comes to how APIs can be designed. Not only does that flexibility enable us to define functions and types that are easier to understand and use – it also lets us create APIs that give a very lightweight first impression, while still progressively disclosing more power and complexity if needed.

This week, let’s take a look at some of the core language features that enable those kind of lightweight APIs to be created, and how we can use them to make a feature or system much more capable through the power of composition.

A trade-off between power and ease of use

Often when we design how our various types and functions will interact with each other, we have to find some form of balance between power and ease of use. Make things too simple, and they might not be flexible enough to enable our features to continuously evolve – but on the other hand, too much complexity often leads to frustration, misunderstandings, and ultimately bugs.

As an example, let’s say that we’re working on an app that lets our users apply various filters to images – for example to be able to edit photos from their camera roll or library. Each filter is made up of an array of image transforms, and is defined using anImageFilterstruct, that looks like this:

structImageFilter {     varname:String    varicon:  (Icon)      vartransforms: [ImageTransform] }

When it comes to theImageTransformAPI, it’s currently modeled as a protocol, which is then conformed to by various types that implement our individual transform operations:

protocolImageTransform {     funcapply (to image:Image)throws->Image}structPortraitImageTransform:ImageTransform{     varzoomMultiplier:  (Double)       funcapply (to image:Image)throws->Image{         ...     } }structGrayScaleImageTransform:ImageTransform{     varbrightnessLevel:BrightnessLevel    funcapply (to image:Image)throws->Image{         ...     } }

One core advantage of the above approach is that, since each transform is implemented as its own type, we’re free to let each type define its own set of properties and parameters – such as howGrayScaleImageTransformaccepts aBrightnessLevelto use when turning an image into grayscale.

We can then combine as many of the above types as we wish in order to form each filter – for example one that gives an image a bit of a“dramatic”look through a series of transforms:

letdramaticFilter=(ImageFilter) ******************** ()     name:"Dramatic",     icon:.drama,     transforms: [      PortraitImageTransform(zoomMultiplier:2.1),      ContrastBoostImageTransform(),      GrayScaleImageTransform(brightnessLevel: .dark)    ]

So far so good – but if we take a closer look at the above API, it can definitely be argued that we’ve chosen to optimize for power and flexibility, rather than for ease of use. Since each transform is implemented as an individual type, it’s not immediately clear what kind of transforms that our code base contains, since there’s no single place in which they can all be instantly discovered.

Compare that to if we would’ve chosen to use an enum to model our transforms instead – which would’ve given us a very clear overview of all possible options:

(enum)  ImageTransform {     caseportrait (zoomMultiplier:Double)     casegrayScale (BrightnessLevel)     casecontrastBoost }

Using an enum would’ve also resulted in very nice and readable call sites – making our API feel much more lightweight and easy to use, since we would’ve been able to construct any number of transforms usingdot-syntax, like this:

letdramaticFilter=ImageFilter(     name:"Dramatic",     icon:.drama,     transforms: [        .portrait(zoomMultiplier:2.1),        .contrastBoost,        .grayScale(.dark)    ]

However, while Swift enums are a fantastic tool in many different situations, this isn’t really one of them.

Since each transform needs to perform vastly different image operations, using an enum in this case would’ve forced us to write one massiveswitchstatement to handle each and every one of those operations – which would most likely become somewhat of a nightmare to maintain.

Light as an enum, capable as a struct

Thankfully, there’s a third option – which sort of gives us the best of both worlds. Rather than using either a protocol or an enum, let’s instead use a struct, which in turn will contain a closure that encapsulates a given transform’s various operations:

structImageTransform {     letclosure: (Image)throws->Image    funcapply (to image:Image)throws->Image{         tryclosure(image)     } }

Note that theapply (to:)method is no longer required, but we still add it both for backward compatibility, and to make our call sites read a bit nicer.

With the above in place, we can now usestatic factory methods and propertiesto create our transforms – each of which can still be individually defined and have its own set of parameters:

extensionImageTransform{     static varcontrastBoost:Self{         ImageTransform{imagein            ...         }     }      static funcportrait (withZoomMultipler multiplier:Double) ->Self{         ImageTransform{imagein            ...         }     }      static funcgrayScale (withBrightness brightness:BrightnessLevel) ->Self{         ImageTransform{imagein            ...         }     } }

ThatSelfcan now be used as a return type for static factory methods is one of thesmall but significant improvements introduced in Swift 5.1.

The beauty of the above approach is that we’re back to the same level of flexibility and power that we had when definingImageTransformas a protocol, while still being able to use a more or less identical dot-syntax as when using an enum:

letdramaticFilter=ImageFilter(     name:"Dramatic",     icon:.drama,     transforms: [        .portrait(withZoomMultipler:2.1),        .contrastBoost,        .grayScale(withBrightness: .dark)    ]

The fact that dot syntax isn’t tied to enums, but can instead be used with any sort of static API, is incredibly powerful – and even lets us encapsulate things one step further, by modeling the above filter creation as a computed static property as well:

extensionImageFilter{     static vardramatic:Self{         ImageFilter(             name:"Dramatic",             icon:.drama,             transforms: [ .portrait(withZoomMultipler:2.1), .contrastBoost, .grayScale(withBrightness: .dark) ]         )     } }

The result of all of the above is that we can now take a really complex series of tasks - applying image filters and transforms - and encapsulate them into an API that, on the surface level, appears as lightweight as simply passing a value to a function:

let  (filtered=image.) ******************* (withFilter) ******************** () .dramatic)

While it's easy to dismiss the above change as purely adding“syntactic sugar”, we haven't only improved the way our API reads, but also the way in which its parts can be composed. Since all transforms and filters are now just values, they can be combined in a huge number of ways - which doesn't only make them more lightweight, but also much more flexible as well.

Variadic parameters and further composition

Next, let's take a look at another really interesting language feature - variadic parameters - and what kind of API design choices that they can unlock.

Let's now say that we're working on an app that uses shape-based drawing in order to create parts of its user interface, and that we've used a similar struct-based approach as above in order to model how each shape is drawn into aDrawingContext:

structShape {     vardrawing: (inoutDrawingContext) ->Void}

Above we use theinoutkeyword to enable a value type (DrawingContext) to be passed as if it was a reference. For more on that keyword, and value semantics in general, check out“Utilizing value semantics in Swift ”.

Just like how we enabledImageTransformvalues ​​to be easily created using static factory methods before, we're now also able to encapsulate each shape's drawing code within completely separate methods - like this:

extensionShape{     funcsquare (at point:Point, sideLength:  (Double) ->Self{         Shape{contextin             letorigin=point.movedBy(                 x: -sideLength /2,                 y: -sideLength /2            )              context.move(to: origin )             drawLine(to: origin .movedBy(x: sideLength) )             drawLine(to: origin .movedBy(x: sideLength, y: sideLength))             drawLine(to: origin .movedBy(y: sideLength) )             drawLine(to: origin )         }     } }

Since each shape is simply modeled as a value, drawing arrays of them becomes quite easy - all we have to do is to create an instance ofDrawingContext, and then pass that into each shape's closure in order to build up our final image:

funcdraw (_Shapes: [Shape]) ->Image{     varcontext=DrawingContext()          forEach{shapein        move(         shape.drawing(& context)     }          returncontext.makeImage() }

Calling the above function also looks quite elegant, since we're again able to use dot syntax to heavily reduce the amount of syntax needed to perform our work:

letimage=draw([    .circle(at: point, radius:10),    .square(at: point, sideLength:5)])

However, let's see if we can take things one step further using variadic parameters. While not a feature unique to Swift, when combined with Swift's really flexible parameter naming capabilities, using variadic parameters can yield some really interesting results.

When a parameter is marked as variadic (by adding the...suffix to its type), we're essentially able to pass any number of values ​​to that parameter - and the compiler will automatically organize those values ​​into an array for us, like this:

(func) ******************** (draw)_Shapes:Shape...) ->Image{     ...          forEach{... } }

With the above change in place, we can now remove all of the array literals from the calls to ourdrawfunction, and instead make them look like this:

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

That might not seem like such a big change, but especially when designing more lower-level APIs that are intended to be used to create more higher-level values ​​(such as ourdrawfunction), using variadic parameters can make those kind of APIs feel much more lightweight and convenient.

However, one drawback of using variadic parameters is that an array of pre-computed values ​​can no longer be passed as a single argument. Thankfully, that can quite easily be fixed in this case, by creating a specialgroupshape that - just like thedrawfunction itself - iterates over an array of underlying shapes and draws them:

extensionShape{     static funcgroup (_Shapes: [Shape]) ->Self{         Shape{contextin            forEach{shapein                move(                 shape.drawing(& context)             }         }     } }

With the above in place, we can now once again easily pass a group of pre-computedShapevalues ​​to ourdrawfunction, like this:

letshapes: [Shape]=loadShapes()letimage=(draw) ******************** ()group(shapes))

What's really cool though, is that not only does the abovegroupAPI enable us to construct arrays of shapes - it also enables us to much more easily compose multiple shapes into more higher-level components. For example, here's how we could express an entire drawing (such as a logo), using a group of composed shapes:

extensionShape{     static funclogo (withSize size:Size) ->Self{         .group([            .rectangle(at: size.centerPoint, size: size),            .text("The Drawing Company", fittingInto: size),            ...        ])     } }

Since the above logo is aShapejust like any other, we can easily draw it with a single call to ourdrawmethod, using the same elegant dot syntax as we used before:

letlogo=draw.logo(withSize: size))

What's interesting is that while our initial goal might've been to make our API more lightweight, in doing so we also made it more composable and more flexible as well.


The more tools that we add to our“API designer's toolbox”, the more likely it is that we ' ll be able to design APIs that strike the right balance between power, flexibility and ease of use.

Making APIs as lightweight as possible might not be our ultimate goal, but by trimming our APIs down as much as we can, we also often discover how they can be made more powerful - by making the way we create our types more flexible, and by enabling them to be composed . All of which can aid us in achieving that perfect balance between simplicity and power.

What do you think? Do you like the kind of lightweight API design used in this article, or do you prefer your APIs to be a bit more verbose? Let me know - along with any questions, comments or feedback that you might have - either via (Twitter) oremail.

Thanks for reading! ***

Brave Browser


About admin

Check Also

Design Ant Design 4.0 is out! · Issue # 21656 · ant-design / ant-design, Hacker News

Design Ant Design 4.0 is out! · Issue # 21656 · ant-design / ant-design, Hacker News

Introduction We released the 4.0 rc version on SEE Conf. After more than a month of feedback collection and adjustment, it time to release 4.0! Thanks to everyone who provided feedback, suggestions, and contributions during this period. We will combine the updates already involved in the rc version and some update recently here. The complete updated…

Leave a Reply

Your email address will not be published. Required fields are marked *