Why not just use protocols (never using extensions) or why not just use extensions (never using protocols)?
Protocols serve as a "blueprint" for a struct, class, or enum—they define a set of required properties and methods, as well as any other protocols that must be conformed to. In other words, they are for requiring functionality.
Extensions offer a way to add extra computed properties and methods to an existing struct, class, enum, or to a protocol. In other words, they are for extending functionality.
Because they serve different purposes, they are not either/or concepts. But they can be used together to bring extended functionality to a whole group of structs or classes at once.
Here's a concrete example where I used this recently. I want to be able to take anything that can be encoded as Data
, and be able to encrypt it, transmit the encrypted form as Data
, then unencrypt it, and decode the unencrypted Data
back into an object. Now, I could write an encrypt
function for each and every struct that needs this functionality. But by using protocols and extensions, I can write it once and have anything that needs it get the functionality for free, just by conforming to the right protocol.
I start with the following protocol:
import CryptoKit
import Foundation
protocol Sealable: Equatable, Codable {}
So if I make something conform to Sealable
, then I require that it first conform to Equatable
and Codable
(you can compare that two instances are equal, and you can convert it to Data
). There are no property or method requirements (the curly brackets are empty).
Next, I need some way to represent the encrypted data, so I create the following generic struct. If you haven't encountered generics before, they are simply a way to make a family of related functions or types simultaneously.
struct Sealed<T: Sealable>: Equatable, Codable {
private let ephemeralKey: Data
private let ciphertext: Data
private let signature: Data
fileprivate init(_ sealedData: (ephemeralKey: Data, ciphertext: Data, signature: Data)) {
self.ephemeralKey = sealedData.ephemeralKey
self.ciphertext = sealedData.ciphertext
self.signature = sealedData.signature
}
}
So this says that I have a type called Sealed
, and it exists for any other type T
, as long as T
conforms to my Sealable
protocol. I also declare that this Sealed
type can be compared for equality, and can be encoded into Data
. I don't have to do anything special to conform to those protocols, because the compiler can figure out how to compare and encode each of its properties, so it can compare and encode the whole struct.
(The details of the three properties are not important for this discussion, but you can read about CryptoKit in this playground.)
Now, by itself, this doesn't really help us that much. But we can use extensions to automatically provide functions to encrypt a Sealable
object, and to decrypt a Sealed
object.
First, we use a protocol extension to provide a seal
function on any type that conforms to Sealable
.
extension Sealable {
func seal(
using encryptionKey: Curve25519.KeyAgreement.PublicKey,
signedBy signingKey: Curve25519.Signing.PrivateKey
) throws -> Sealed<Self> {
let data = try JSONEncoder().encode(self)
let sealedData = try encrypt(data, with: encryptionKey, signedBy: signingKey)
return Sealed<Self>(sealedData)
}
}
Note the important part—if my type T
is Sealable
, it will have a seal
function which returns an object of type Sealed<T>
.
Similarly, we can use an extension on the Sealed
type to provide an unseal
function on any Sealed
type.
extension Sealed {
func unseal(
using encryptionKey: Curve25519.KeyAgreement.PrivateKey,
signedBy signingKey: Curve25519.Signing.PublicKey
) throws -> T {
let sealedData = (ephemeralKey, ciphertext, signature)
let data = try decrypt(sealedData, with: encryptionKey, signedBy: signingKey)
return try JSONDecoder().decode(T.self, from: data)
}
}
Once again, if I have any type Sealed<T>
, it will have an unseal
function that returns a value of type T
.
(The encrypt
and decrypt
functions are defined in the playground mentioned above.)
Now the whole encryption and decryption process is as simple as declaring Sealable
protocol conformance, and using the seal
and unseal
methods.
struct MyStruct: Sealable {
var sensitiveInformation: String
}
// Create encryption keys
let senderPrivateKey = Curve25519.Signing.PrivateKey()
let senderPublicKey = senderPrivateKey.publicKey
// Create signing keys
let receiverPrivateKey = Curve25519.KeyAgreement.PrivateKey()
let receiverPublicKey = receiverPrivateKey.publicKey
// Encrypt, unencrypt, and test equality
let myStruct = MyStruct(sensitiveInformation: "Password")
let sealed = try! myStruct.seal(using: receiverPublicKey, signedBy: senderPrivateKey)
let unsealed = try! sealed.unseal(using: receiverPrivateKey, signedBy: senderPublicKey)
assert(myStruct == unsealed)
If I want to encrypt a different type, I need a one-line extension to conform to the protocol. I don't have to write the function all over again.
extension String: Sealable {}
let string = "Some sensitive information"
let sealedString = try! string.seal(using: receiverPublicKey, signedBy: senderPrivateKey)
let unsealedString = try! sealedString.unseal(using: receiverPrivateKey, signedBy: senderPublicKey)
assert(string == unsealedString)