「英语」Stanford cs193p Protocols Enum Optional
本文是斯坦福大学 cs193p 公开课程第05集的相关笔记。
cs193p 课程介绍:
The lectures for the Spring 2023 version of Stanford University's course CS193p (Developing Applications for iOS using SwiftUI) were given in person but, unfortunately, were not video recorded. However, we did capture the laptop screen of the presentations and demos as well as the associated audio. You can watch these screen captures using the links below. You'll also find links to supporting material that was distributed to students during the quarter (homework, demo code, etc.).
cs193p 课程网址: https://cs193p.sites.stanford.edu/2023
// EmojiMemoryGameView.swift
struct EmojiMemoryGameView: View {
@ObservedObject var viewModel: EmojiMemoryGame
var body: some View {
VStack {
ScrollView {
cards
.animation(.default, value: )
}
Button("Shuffle") {
viewModel.shuffle()
}
}
.padding()
}
...
We can do an animation by adding .animation
view modifier. The value
means it will only animate when this value changes.
Equatable Protocol
However, we will receive an error from Swift, "Referencing instance method 'animation(_:value:)' on 'Array' requires that 'MemoryGame\<String\>.Card' conform to 'Equatable'". This means if something changed, the animation takes a copy of it. And when something also changed, it takes another copy of it. The animation will animate only when the two copy are not equal. So we need to implement our Card equitable.
// MemorizeGame.swift
...
struct Card: Equatable {
static func == (lhs: Card, rhs: Card) -> Bool {
return lhs.isFaceUp == rhs.isFaceUp &&
lhs.isMatched == rhs.isMatched &&
lhs.content == rhs.content
}
var isFaceUp = true
var isMatched = false
let content: CardContent
}
...
We requies to write a static function that takes a function and return a Bool in order to implement Equatable
protocol. The function need two paramters, a left hand side Card
and a right hand side Card
.
But the error message "Referencing operator function '==' on 'Equatable' requires that 'CardContent' conform to 'Equatable'" from Swift expresses we need to make our CardContent
equatable. Card Content is our "don't care".
// MemorizeGame.swift
...
struct MemoryGame<CardContent> where CardContent: Equatable {
...
...
struct Card: Equatable {
static func == (lhs: Card, rhs: Card) -> Bool {
return lhs.isFaceUp == rhs.isFaceUp &&
lhs.isMatched == rhs.isMatched &&
lhs.content == rhs.content
}
var isFaceUp = true
var isMatched = false
let content: CardContent
}
}
To make our CardContent
equatable, we use where CardContent: Equatable
to achieve it. That means, our "don't care" are cares a little bit.
struct MemoryGame<CardContent> where CardContent: Equatable {
...
...
struct Card: Equatable {
var isFaceUp = true
var isMatched = false
let content: CardContent
}
}
Another cool feature in Swift, if we just compre each thing like above, we can just remove it.
It did works, but it looks not like card shuffle.
// EmojiMemoryGameView.swift
...
var cards: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 85), spacing: 0)], spacing: 0) {
ForEach(viewModel.cards.indices, id: \.self) { index in
CardView(viewModel.cards[index])
.aspectRatio(2/3, contentMode: .fit)
.padding(4)
}
}
.foregroundColor(.orange)
}
...
Because our ForEach
iterate the indices of the array. It going from card 0, 1, 2, 3 ... and make the Card view for each one. When we shuffle the card, we move the card from No. 0 to 7 for example. But the ForEach
still showing the card from card index from 0, 1, 2...
We want to move the card itself, the Card view.
// EmojiMemoryGameView.swift
...
var cards: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 85), spacing: 0)], spacing: 0) {
ForEach(viewModel.cards, id: \.self) { card in
CardView(card)
.aspectRatio(2/3, contentMode: .fit)
.padding(4)
}
}
.foregroundColor(.orange)
}
...
We made some changes. The code above will produce error "Referencing initializer 'init(_:id:content:)' on 'ForEach' requires that 'MemoryGame\<String\>.Card' conform to 'Hashable'".
// EmojiMemoryGameView.swift
...
var cards: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 85), spacing: 0)], spacing: 0) {
ForEach(viewModel.cards) { card in
CardView(card)
.aspectRatio(2/3, contentMode: .fit)
.padding(4)
}
}
.foregroundColor(.orange)
}
...
Now, it's time to talk about id: \.self
. It means what I use to identify these things. self
means use the thing itself, it works great for like Int or kinds of stuff. Here, we want to make the id
unique. But it will not works for us, Card
includes isFaceUp
, isMatch
all complement. When we click the card, the id
changes. We need something that just to identify the that Card
.
Thus, we remove the id
. And it produces another error, "Referencing initializer 'init(_:content:)' on 'ForEach' requires that 'MemoryGame\<String\>.Card' conform to 'Identifiable'".
So, we will make our card identifiable.
Identifiable Protocol
// MemorizeGame.swift
import Foundation
struct MemoryGame<CardContent> where CardContent: Equatable {
private(set) var cards: Array<Card>
init(numberOfPairsOfCards: Int, cardContentFactory: (Int) -> CardContent) {
cards = []
// add numberOfParisOfCards x 2 cards
for pairIndex in 0..<max(2, numberOfPairsOfCards) {
let content = cardContentFactory(pairIndex)
cards.append(Card(content: content, id: "\(pairIndex+1)a"))
cards.append(Card(content: content, id: "\(pairIndex+1)b"))
}
}
...
...
struct Card: Equatable, Identifiable {
var isFaceUp = true
var isMatched = false
let content: CardContent
var id: String
}
}
We added an id typed as a String, and we assign the id when we create new cards. The id would looks like 1a, 1b, 2a, 2b, 3a, 3b, etc...
Wow, it does working!
CustomDebugStringConvertible
Our currrent print message is pretty complicated. So, we can make it better.
// MemorizeGame.swift
...
struct Card: Equatable, Identifiable, CustomDebugStringConvertible {
var isFaceUp = true
var isMatched = false
let content: CardContent
var id: String
var debugDescription: String {
"\(id): \(content) \(isFaceUp ? "up" : "down") \(isMatched ? "matched": "")"
}
}
...
We implement CustomDebugStringConvertible protocol for our Card
, and defined debugDescription
.
Now, it looks really simplified.
ViewModel Intent - Part 1
// EmojiMemoryGameView.swift
...
var cards: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 85), spacing: 0)], spacing: 0) {
ForEach(viewModel.cards) { card in
CardView(card)
.aspectRatio(2/3, contentMode: .fit)
.padding(4)
.onTapGesture {
viewModel.choose(card)
}
}
}
.foregroundColor(.orange)
}
...
// MemorizeGame.swift
...
func choose(_ card: Card) {
card.isFaceUp.toggle()
}
...
Now, we can implement user's intent (flip the card).
We used the .onTapGesture
and .toggle()
try to flip the card when user touch the screen. However, because card
is a value type. the choose function gets a copy of the card, so we can't .toggle()
it.
// MemorizeGame.swift
...
mutating func choose(_ card: Card) {
let chosenIndex = index(of: card)
cards[chosenIndex].isFaceUp.toggle()
}
func index(of card: Card) -> Int {
for index in cards.indices {
if cards[index].id == card.id {
return index
}
}
return 0 // FIXME: bogus!
}
...
It turns out, we will use the index of the card directly modify the cards array. We implemented a function called index to find card card index.
Currently, if we can't find a card, we will just return 0, which is our first card. Before we know how to fix that, let's understand enum first. For now, it should works!
Enmu
- Another variety of data structure in addition to
struct
andclass
It can only have discrete states ...
enum FastFoodMenuItem {
case hamburger
case fries
case drink
case cookie
}
An enum is a value type (like struct
), so it is copied as it is passed around.
- Associated Data
Each state can (but does not have to) have its own "associated data" ...
enum FastFoodMenuItem {
case hamburger(numberOfPatties: Int)
case fries(size: FryOrderSize)
case drink(String, ounces: Int) // the unnamed String is the brand, e.g. "Coke"
case cookie
}
Note that the drink case has 2 pieces of associated data (one of them "unnamed")
In the example above, FryOrderSize
would also probably be an enum, for example ...
enum FryOrderSize {
case large
case small
}
- Setting the value of an enum
When you set the value of an enum you must provide the associated data(if any) ...
let menuItem: FastFoodMenuItem = FastFoodMenuItem.hamburger(patties: 2)
var otherItem: FastFoodMenuItem = FastFoodMenuItem.cookie
Swift can infer the type on one side of the assignment or the other (but not both) ...
let menuItem = FastFoodMenuItem.hamburger(patties: 2)
var otherItem: FastFoodMenuItem = .cookie
// Swift can't figure this out
var yetAnotherItem = .cookie
- Checking the enum's state
An enum's state is usually checked with a switch
statement ...
(Although we could use an if
statement, but this is unusual if there is associated data)
var menuItem = FastFoodMenuItem.hamburger(patties: 2)
switch menuItem {
case FastFoodMenuItem.hamburger: print("burger")
case FastFoodMenuItem.fries: print("fries")
case FastFoodMenuItem.drink: print("drink")
case FastFoodMenuItem.cookie: print("cookie")
}
Note that we are ignoring the "associated data" above ... so far ...
The code would print "burger" on the console.
var menuItem = FastFoodMenuItem.hamburger(patties: 2)
switch menuItem {
case .hamburger: print("burger")
case .fries: print("fries")
case .drink: print("drink")
case .cookie: print("cookie")
}
It is not necessary to user the fully-expressed FastFoodMenuItem.fries
inside the switch (since Swift can infer the FastFoodMenuItem
part of that)
- break
If you don't want to do anything in a given case, user break
...
var menuItem = FastFoodMenuItem.hamburger(patties: 2)
switch menuItem {
case .hamburger: break
case .fries: print("fries")
case .drink: print("drink")
case .cookie: print("cookie")
}
The code would print nothing on the console.
- default
A switch must handle all possible cases (although you can default
uninteresting cases) ...
var menuItem = FastFoodMenuItem.cookie
switch menuItem {
case .hamburger: break
case .fries: print("fries")
default: print("other")
}
If the menuItem were a cookie, the above code would print "other" on the console.
You can switch on any type (not just enum), by the way, for example ...
let s: String = "hello"
switch s {
case "goodbye": ...
case "hello": ...
default: ... // gotta have this for String because switch has to cover ALL cases
}
- Mutiple lines allowed
Each case in a switch can be mutiple lines and does NOT fall through to the next case ...
var menuItem = FastFoodMenuItem.fries(size: FryOrderSize.large)
switch menuItem {
case .hamburger: print("burger")
case .fries:
print("yummy")
print("fries")
case .drink:
print("drink")
case .cookie: print("cookie")
}
The above code would print "yummy" and "fries" on the console, but not "drink"
If you put the keyword fallthrough
as the last line of a case, it will fall through.
- What about the associated data?
Associated data is accessed through a switch statement using this let
syntax
var menuItem = FastFoodMenuItem.drink("Coke", ounces: 32)
switch menuItem {
case .hamburger(let pattyCount): print("a burger with \(pattyCount) patties")
case .fries(let size): print("a \(size) order of fries!")
case .drink(let brand, let ounces): print("a \(ounces)oz \(brand)")
case .cookie: print("a cookie!")
}
Note that the local variable that retrieves the associated data can have a different name
(e.g. pattyCount
above versus patties in the enum declaration)
(e.g. brand above versus not even having a name in the enum declaration)
- Methods yes, (stored) Properties no
An enum can have methods (and computed properties) but no stored properties ...
enum FastFoodMenuItem {
case hamburger(numberOfPatties: Int)
case fries(size: FryOrderSize)
case drink(String, ounces: Int)
case cookie
func isIncludedInSpecialOrder(number: Int) -> Bool { }
var colories: Int { // switch on self and calculate caloric value here }
}
An enum's state is entriely which case it is in and that case's associated data, nothing more.
In an enum's own methods, you can test the enum's state (and get associated data) using self ...
enum FastFoodMenuItem {
...
func isIncludedInSpecialOrder(number: Int) -> Bool {
switch self {
case .hamburger(let pattyCount): return pettyCount == number
case .fries, .cookie: return true // a drink and cookie in every special order
case .drink(_, let ounces): return ounces == 16 // & 16oz driink of any kind
}
}
}
Special order 1 is a single patty burger, 2 is a double patty (3 is a triple, etc.?!)
- Getting all the cases of an enumeration
enum TeslaModel: CaseIterable {
case X
case S
case Three
case Y
}
Now, this enum will have a static var allCases that you can iterate over.
for model in TestlaModel.allCases {
reportSalesNumbers(for: model)
}
func reportSalesNumbers(for model: TeslaModel) {
switch model { ... }
}
Optional
An optional is just an enum. Period, nothing more.
It essentially looks like this ...
enum Optional<T> { // a generic type
case none
case some(T) // the some case has associated value of type T
}
You can see that it can only have two values: is set (some) or not set (none).
In the is set case, it can have some associated value tagging along (of "don't care type" T).
Where do we use Optional?
Anytime we have a value that can sometimes be "not set" or "unspecified" or "undetermined".
This happens surprisinly often.
That's why Swift introduces a lot of "syntactic sugar" to make it easy to use Optionals ...
Declaring something of type Optional<T>
can be done with the syntax T?
You can then assign it the value nil
(Optional.none)
Or you can assign it something of the type T (Optional.some with associated value = that value).
Note that Optionals always start out with an implicit = nil.
var hello: String? var hello: Optional<String> = .none
var hello: String? = "hello" var hello: Optional<String> = .some("hello")
var hello: String? = nil var hello: Optional<String> = .none
You can access the associated value either by force (with !) ...
let hello: String? = ...
print(hello!)
switch hello {
case .none: // raise an exception (crash)
case .some(let data): print(data)
}
Or "safely" using if let
and then using the safely-gotten associated value in { } (else allowed too).
if let safehello = hello {
print(safehello)
} else {
// do something else
}
switch hello {
case .none: // raise an exception (crash)
case .some(let safehello): print(safehello)
}
You may also use the shorter version, same as above.
if let hello {
print(hello)
} else {
// do something else
}
There's also ??
which does "Optional defaulting". It's called the "nil-coalescng operator"
let hello: String? = ...
let y = x ?? "foo"
switch hello {
case .none: y = "foo"
case .some(let data): y = data
}
Now, let's jump back to Code
ViewModel Intent - Part 2
// MemorizeGame.swift
...
private func index(of card: Card) -> Int? {
for index in cards.indices {
if cards[index].id == card.id {
return index
}
}
return nil
}
...
Let's fix the bogus by using Optional.
// MemorizeGame.swift
...
mutating func choose(_ card: Card) {
if let chosenIndex = index(of: card) {
cards[chosenIndex].isFaceUp.toggle()
}
}
...
We also need to change to choose function, because chosenIndex
is now Optioanal type. We can force unwrap it by using !
, but our program will crash if index
return nil. So, we use safe unwrap.
functions as argument
// MemorizeGame.swift
...
mutating func choose(_ card: Card) {
if let chosenIndex = cards.firstIndex(where: { $0.id == card.id }) {
cards[chosenIndex].isFaceUp.toggle()
}
}
...
We don't have to self implement a function index
. We can actually find the chosenIndex
Flip the cards down when NOT matched
// MemorizeGame.swift
...
var indexOfTheOneAndOnlyFaceUpCard: Int?
mutating func choose(_ card: Card) {
if let chosenIndex = cards.firstIndex(where: { $0.id == card.id }) {
if !cards[chosenIndex].isFaceUp && !cards[chosenIndex].isMatched {
if let potentialMatchIndex = indexOfTheOneAndOnlyFaceUpCard {
// Two Cards Face Up
if cards[chosenIndex].content == cards[potentialMatchIndex].content {
cards[chosenIndex].isMatched = true
cards[potentialMatchIndex].isMatched = true
}
indexOfTheOneAndOnlyFaceUpCard = nil
} else {
for index in cards.indices {
cards[index].isFaceUp = false
}
indexOfTheOneAndOnlyFaceUpCard = chosenIndex
}
}
cards[chosenIndex].isFaceUp = true
}
}
...
Our game logic now looks right, but we need to hide the cards when they matched.
// EmojiMemoryGameView.swift
...
var body: some View {
ZStack {
let base = RoundedRectangle(cornerRadius: 12)
Group {
base.fill(.white)
base.strokeBorder(lineWidth: 2)
Text(card.content)
.font(.system(size: 200))
.minimumScaleFactor(0.01)
.aspectRatio(1, contentMode: .fit)
}
.opacity(card.isFaceUp ? 1 : 0)
base.fill().opacity(card.isFaceUp ? 0 : 1)
}
.opacity(card.isFaceUp || !card.isMatched ? 1 : 0)
}
...
So, we go back to our view, let matched cards opacity be 0.
Our main game logic should work now.
get+set computed property
// MemorizeGame.swift
...
var indexOfTheOneAndOnlyFaceUpCard: Int? {
get {
var faceUpCardIndices = [Int]()
for index in cards.indices {
if cards[index].isFaceUp {
faceUpCardIndices.append(index)
}
}
if faceUpCardIndices.count == 1 {
return faceUpCardIndices.first
} else {
return nil
}
}
set {
for index in cards.indices {
if index == newValue {
cards[index].isFaceUp = true
} else {
cards[index].isFaceUp = false
}
}
}
}
...
We can optimize the game logic by make indexOfTheOneAndOnlyFaceUpCard
a computed property. This computed property will return the other face up cards (or nil) when being get. (something = indexOfTheOneAndOnlyFaceUpCard) When being set (indexOfTheOneAndOnlyFaceUpCard = something), it will set the cards passed into face up, all other cards face down.
// MemorizeGame.swift
...
mutating func choose(_ card: Card) {
if let chosenIndex = cards.firstIndex(where: { $0.id == card.id }) {
if !cards[chosenIndex].isFaceUp && !cards[chosenIndex].isMatched {
if let potentialMatchIndex = indexOfTheOneAndOnlyFaceUpCard {
// Two Cards Face Up
if cards[chosenIndex].content == cards[potentialMatchIndex].content {
cards[chosenIndex].isMatched = true
cards[potentialMatchIndex].isMatched = true
}
} else {
indexOfTheOneAndOnlyFaceUpCard = chosenIndex
}
}
cards[chosenIndex].isFaceUp = true
}
}
...
We also need to change our choose
function, because our computed property does lots of thing for us.
Optimize
// MemorizeGame.swift
...
var indexOfTheOneAndOnlyFaceUpCard: Int? {
get {
let faceUpCardIndices = cards.indices.filter { index in cards[index].isFaceUp }
return faceUpCardIndices.count == 1 ? faceUpCardIndices.first : nil
}
set {
cards.indices.forEach { cards[$0].isFaceUp = (newValue == $0) }
}
}
...
Now, we used funtional programming to optimize our code. We can also use extension
to make the code even better.
Extension
// MemorizeGame.swift
...
import Foundation
struct MemoryGame<CardContent> where CardContent: Equatable {
private(set) var cards: Array<Card>
...
...
var indexOfTheOneAndOnlyFaceUpCard: Int? {
get { cards.indices.filter { index in cards[index].isFaceUp }.only }
set { cards.indices.forEach { cards[$0].isFaceUp = (newValue == $0) } }
}
...
...
}
extension Array {
var only: Element? {
count == 1 ? first : nil
}
}
We extend the array so we can use .only
in our indexOfTheOneAndOnlyFaceUpCard
computed property.
评论已关闭