00:06 Today we'll be writing a structural programming library. Structural
programming refers to the idea of writing programs on top of the structures of
data types. Features in Swift that use structural programming are Codable
,
which allows us to write serialization based on the structure of a type;
Mirror
, which we use to dynamically introspect values; and most recently
added, macros.
00:36 Writing a macro means a lot of typing and diving deep into the
syntax tree. This only allows us to write certain kinds of programs on the
structures of enums and structs. The library we want to write is much simpler;
we'll convert a struct into a structural representation of its properties, and
then we'll write algorithms on that structural representation.
Structure of a Struct
01:18 Let's first create a sample model — a Book
struct with two
properties:
import Foundation
struct Book {
var title: String
var published: Date
}
01:30 In another file, we start writing our library with types that can
represent the structure of Book
. The first part of the library is a Struct
type, which can hold a struct's name and some generic value for its properties:
import Foundation
struct Struct<Properties> {
var name: String
var properties: Properties
}
01:48 Concretely, the Properties
parameter will be a list of
Property
values:
struct Property<Value> {
var name: String
var value: Value
}
02:08 The structure of Book
can be represented by a Struct
with the
name "Book"
and, for each of its properties, a Property
with this name as a
string. What we're missing is a way to combine the properties into one type.
Modeling this as an array isn't an option, because then the Value
parameter of
all properties would have to be the same type.
02:40 Instead, we'll use a linked list to create a type-safe
representation of differently typed properties:
struct List<Head, Tail> {
var head: Head
var tail: Tail
}
02:56 We're now almost ready to define the structure of Book
. We add a
type alias called Structure
to the Book
struct, and we assign a Struct
with a list of properties. The list's head is a Property<String>
. The tail is
another list, whose head is a Property<Date>
, and we don't yet know what the
tail of this nested list should be, so let's just use Int
as a placeholder
type:
struct Book {
var title: String
var published: Date
typealias Structure = Struct<List<Property<String>, List<Property<Date>, Int>>>
}
03:38 Instead of Int
, we need something to indicate that there's
nothing there — a sentinel value. For this, we write an Empty
type, which will
solely be used to represent the end of a list:
struct Empty { }
struct Book {
var title: String
var published: Date
typealias Structure = Struct<List<Property<String>, List<Property<Date>, Empty>>>
}
03:58 Next, we need a way to convert a Book
value into this structural
representation and a way to create a Book
from that representation. We add a
computed property to convert a value to a Structure
. Because Swift already
knows the structure is a Struct
, we can write .init
, and it automatically
completes the correct initializer for us. We pass in "Book"
for the struct's
name. For the properties
, we write .init
again to let the compiler add stubs
for the list's head and tail. The list's head should hold the book's title
property, which we initialize with the "title"
name and the book's title
as
the value. The tail is another list, with the published
property as its head
and an Empty
value as its tail:
struct Book {
var title: String
var published: Date
typealias Structure = Struct<List<Property<String>, List<Property<Date>, Empty>>>
var to: Structure {
.init(name: "Book", properties: .init(head: .init(name: "title", value: title), tail: .init(head: .init(name: "published", value: published), tail: .init())))
}
}
05:41 It's a bit of a hassle to type out this structure, but later on,
we'll write a macro to automatically generate the Structure
type alias, the
to
property, and the from
function we need to create a Book
value from a
structural representation:
struct Book {
static func from(_ s: Structure) -> Self {
}
}
06:05 Inside from
, we want to construct a Book
and pull values for
its properties out of the s
structure. The title value can be found in the
property list's head, and for the publish date value, we go into the list nested
in the tail, and we take the value of the nested list's head:
struct Book {
var title: String
var published: Date
typealias Structure = Struct<List<Property<String>, List<Property<Date>, Empty>>>
var to: Structure {
.init(name: "Book", properties: .init(head: .init(name: "title", value: title), tail: .init(head: .init(name: "published", value: published), tail: .init())))
}
static func from(_ s: Structure) -> Self {
.init(title: s.properties.head.value, published: s.properties.tail.head.value)
}
}
06:42 If there'd be a third property, we'd read from
s.properties.tail.tail.head.value
— we just keep appending .tail
to go
deeper into the linked list.
Displaying Structures
06:56 Now we can write a generic algorithm on the structure — for
example, one that converts a Struct
into a SwiftUI View
. This could be
useful as a debugging tool that lets us display a struct value in a debug
console in our app. By writing the algorithm for the structural representation,
we can make this algorithm work for Book
values, but also for any other
structure.
07:36 In a new file, we import SwiftUI, and we can create a protocol
called ToView
. Types conforming to this protocol need to define a view
property that returns some view:
import SwiftUI
protocol ToView {
associatedtype V: View
var view: V { get }
}
07:59 This protocol is literally the definition of View
, but with
different names, but by using a custom protocol, we get to write our own
conformances. For example, Empty
can conform by just returning an EmptyView
:
extension Empty: ToView {
var view: some View {
EmptyView()
}
}
08:29 We can conform Property
as long as its Value
type also
conforms to ToView
. The view it returns can be a LabeledContent
with the
property's name as the label and its value's view as the content:
extension Property: ToView where Value: ToView {
var view: some View {
LabeledContent(name) {
value.view
}
}
}
09:11 List
conditionally conforms to ToView
if both its Head
and
Tail
types conform. For its view, we could use a VStack
to lay out the head
and tail views, but it'll be more flexible if we just return a group of views
and let the caller of the view
property decide what their container should be:
extension List: ToView where Head: ToView, Tail: ToView {
var view: some View {
head.view
tail.view
}
}
To return the views separately, we need to mark the view
property as being a
view builder. And by doing this in the protocol, we give every conforming type
this capability:
protocol ToView {
associatedtype V: View
@ViewBuilder var view: V { get }
}
10:04 Then there's only Struct
left. To construct its view, we can
return a VStack
with the struct's name in bold, followed by the properties:
extension Struct: ToView where Properties: ToView {
var view: some View {
VStack {
Text(name).bold()
properties.view
}
}
}
Displaying Book Struct
10:30 In ContentView
, we create a sample Book
value, and we try to
display its structure:
struct ContentView: View {
var book = Book(title: "Thinking in SwiftUI", published: .now)
var body: some View {
book.to.view
.padding()
}
}
10:51 This doesn't yet compile and the warnings are clear: the ToView
conformance of Property
dictates that we conform both Date
and String
to
ToView
. So, we go back to our ToView
file and add conformances for the
primitive types. For String
, we can return a Text
view containing the string
itself:
extension String: ToView {
var view: some View {
Text(self)
}
}
11:38 And for Date
, we can use the Text
initializer that takes a
Date
and a format:
extension Date: ToView {
var view: some View {
Text(self, format: .dateTime)
}
}
11:58 Now our code compiles, and when we run it, we see a view
describing our Book
value and listing the name of the struct and the name and
value of each property:
12:13 We've now created a rather complicated way to display this tiny
bit of information. But next time, we'll work on a macro that generates the
structural representation of a Struct
. That means we only have to add another
property to Book
, and the displayed structure will automatically change to
include that new field.