Swift 5.5新特性(上)
本文對
https://www。
whatsnewinswift。com/?
from=5。4&to=5。5
進行了節選和翻譯
喜歡文章?不如來個 ❤️➕ 三連?關注專欄,關注我
更多內容,歡迎關注公眾號 「Swift花園」
相比於 Xcode 和 SwiftUI 的新特性和改進,Swift 語言本身在 5。5 版本迎來的變化可謂巨大了。Paul Hudson 在其 “What‘s new in Swift?” 網站上已經更新了 Swift 5。4 到 Swift 5。5 的變化,文件和範例都非常詳細,同時也很瑣碎。筆者挑選了自己認為比較重要的特性,在本文中和讀者一起探索學習。
動手實踐是最好的學習方式。建議想要嘗試和學習 Swift 5。5 新特性,同時又希望省點力的讀者,可以下載 Paul 放在 Github 上的 Playground,利用裡面的程式碼快速上手實踐。本文在介紹 Swift 5。5 新特性時,也會直接使用該 Playground 裡面的程式碼範例。
與併發無關的新特性
#if
字尾成員表示式
SE-0308 使得 Swift 可以在後綴成員表示式前使用
#if
條件。程式碼範例如下:
Text
(
“Welcome”
)
#if
os
(
iOS
)
。
font
(。
largeTitle
)
#else
。
font
(。
headline
)
#endif
條件還可以巢狀:
#if
os
(
iOS
)
。
font
(。
largeTitle
)
#if
DEBUG
。
foregroundColor
(。
red
)
#endif
#else
。
font
(。
headline
)
#endif
注意:條件分支之後必須都是字尾表示式,不能是其他型別的表示式。
CGFloat
和
Double
型別可以互換使用
SE-0307 引入:Swift 現在能夠在
CGFloat
和
Double
之間按需自動進行隱式轉換。
如 Paul 所言,這真的是一個微小但卻提升 Swift 程式設計師生活質量的改進。
大量使用
CGFloat
的 API,現在由 Swift 默默地幫你橋接到
Double
。
帶關聯值的列舉型別的
Codable
自動合成
SE-0295 升級了 Swift 的
Codable
系統,現在能支援帶關聯值的列舉。例如:
enum
Weather
:
Codable
{
case
sun
case
wind
(
speed
:
Int
)
case
rain
(
amount
:
Int
,
chance
:
Int
)
}
上面的程式碼有一個簡單的 case,一個帶單一關聯值的 case,還有一個帶兩個關聯值的 case。我們可以在 Swift 5。5 中用
JSONEncoder
或者其他型別的編碼器對下面的列舉變數進行編碼並取得 JSON 字串。
let
forecast
:
[
Weather
]
=
[
。
sun
,
。
wind
(
speed
:
10
),
。
sun
,
。
rain
(
amount
:
5
,
chance
:
50
)
]
do
{
let
result
=
try
JSONEncoder
()。
encode
(
forecast
)
let
jsonString
=
String
(
decoding
:
result
,
as
:
UTF8
。
self
)
(
jsonString
)
}
catch
{
(
“Encoding error:
\(
error
。
localizedDescription
)
”
)
}
lazy
關鍵字現在也能用於區域性作用域
func
printGreeting
(
to
:
String
)
->
String
{
(
“In printGreeting()”
)
return
“Hello,
\(
to
)
”
}
func
lazyTest
()
{
(
“Before lazy”
)
lazy
var
greeting
=
printGreeting
(
to
:
“Paul”
)
(
“After lazy”
)
(
greeting
)
}
lazyTest
()
在實踐中,這個特性對於選擇性執行程式碼非常有用:你可以以懶載入的方式準備某個結果,但只有在實際用到該結果時才會執行相關的工作。
屬性包裝器現在可用於函式和閉包的引數
SE-0293 拓展了屬性包裝器,現在它們可以應用於函式和閉包的引數。
對引數應用屬性包裝器並不會改變引數傳遞的不可變屬性,並且你仍然可以透過下劃線來訪問包裝器內封裝的型別。
看下面的程式碼:
func
setScore1
(
to
score
:
Int
)
{
(
“Setting score to
\(
score
)
”
)
}
// 呼叫
setScore1
(
to
:
50
)
setScore1
(
to
:
-
50
)
setScore1
(
to
:
500
)
假如我們希望分數只能處於
0。。。100
的範圍,我們可以編寫一個簡單的屬性包裝器:
@
propertyWrapper
struct
Clamped
<
T
:
Comparable
>
{
let
wrappedValue
:
T
init
(
wrappedValue
:
T
,
range
:
ClosedRange
<
T
>)
{
self
。
wrappedValue
=
min
(
max
(
wrappedValue
,
range
。
lowerBound
),
range
。
upperBound
)
}
}
然後把上面的函式改寫成:
func
setScore2
(@
Clamped
(
range
:
0。
。。
100
)
to
score
:
Int
)
{
(
“Setting score to
\(
score
)
”
)
}
setScore2
(
to
:
50
)
setScore2
(
to
:
-
50
)
setScore2
(
to
:
500
)
用相同的數值呼叫
setScore2()
產生的結果和
setScore()
不同,因為數字會被 clamped 為 50,0,100。
在泛型上下文中使用靜態成員查詢
SE-0299 使得 Swift 可以在泛型函式中執行靜態成員查詢。聽起來有點晦澀,看下面的例子更容易理解。
之前我們在 SwiftUI 中可能寫過這樣的程式碼:
Toggle
(
“Example”
,
isOn
:
。
constant
(
true
))
。
toggleStyle
(
SwitchToggleStyle
())
現在可以改成這樣:
Toggle
(
“Example”
,
isOn
:
。
constant
(
true
))
。
toggleStyle
(。
switch
)
與併發相關的新特性
async
和
await
關鍵字
SE-0296 為 Swift 引入了非同步函式,這使得我們可以像編寫同步程式碼那樣處理非同步程式碼。簡單來說,我們需要兩個步驟:第一步是用新的
async
關鍵字標記函式為非同步,第二步是用
await
關鍵字來呼叫非同步函式。這同 C# 和 JavaScript 是類似的。
當然,有 async/await 機制的程式語言除了 C# 和 JavaScript,還有 Python, F#, Kotlin, Rust, Dart 等,它們有的是實現為關鍵字,有的則是實現為函式或者庫。Swift 的步伐雖然慢了一些,但也不算晚。
在引入
async
和
await
之前,假設我們要實現一個邏輯:從服務端拉取海量的資料記錄,然後進行計算,最後上傳回伺服器。那麼程式碼可能會長下面這個樣子:
func
fetchWeatherHistory
(
completion
:
@
escaping
([
Double
])
->
Void
)
{
// 省略複雜的網路程式碼,這裡我們直接用100000條記錄來代替
DispatchQueue
。
global
()。
async
{
let
results
=
(
1。
。。
100_000
)。
map
{
_
in
Double
。
random
(
in
:
-
10。
。。
30
)
}
completion
(
results
)
}
}
func
calculateAverageTemperature
(
for
records
:
[
Double
],
completion
:
@
escaping
(
Double
)
->
Void
)
{
// 對陣列求和然後求平均值
DispatchQueue
。
global
()。
async
{
let
total
=
records
。
reduce
(
0
,
+
)
let
average
=
total
/
Double
(
records
。
count
)
completion
(
average
)
}
}
func
upload
(
result
:
Double
,
completion
:
@
escaping
(
String
)
->
Void
)
{
// 省略網路程式碼,傳送回伺服器
DispatchQueue
。
global
()。
async
{
completion
(
“OK”
)
}
}
上面的網路程式碼我有意用捏造的資料來代替了,因為網路部分跟我們的主題無關。讀者只需要知道這些函式很耗時,所以我們採用了完成閉包來處理,而不是阻塞的方式。當我們要使用這幾個函式時,我們需要將它們連結起來,給每個函式呼叫都提供完成閉包。程式碼可能如下:
fetchWeatherHistory
{
records
in
calculateAverageTemperature
(
for
:
records
)
{
average
in
upload
(
result
:
average
)
{
response
in
(
“Server response:
\(
response
)
”
)
}
}
}
希望你能看出上面這種方式的問題:
完成閉包可能會被多處呼叫,也可能被忘記呼叫
@escaping (String) -> Void
這樣的引數語法閱讀起來相對困難
隨著每一層完成閉包的增加,呼叫方的程式碼結構會演變成所謂的“末日金字塔”(也常稱為“回撥地獄”)
在 Swift 5。0 引入
Result
之前,完成處理要回傳錯誤也是更加困難
在 Swift 5。5,我們可以透過標記這些函式為非同步,並且返回值而不是依賴完成閉包來解決上面提到的問題,程式碼如下:
func
fetchWeatherHistory
()
async
->
[
Double
]
{
(
1。
。。
100_000
)。
map
{
_
in
Double
。
random
(
in
:
-
10。
。。
30
)
}
}
func
calculateAverageTemperature
(
for
records
:
[
Double
])
async
->
Double
{
let
total
=
records
。
reduce
(
0
,
+
)
let
average
=
total
/
Double
(
records
。
count
)
return
average
}
func
upload
(
result
:
Double
)
async
->
String
{
“OK”
}
藉助非同步返回值的語法,我們已經可以移除很多程式碼,而呼叫方的程式碼則更簡潔:
func
processWeather
()
async
{
let
records
=
await
fetchWeatherHistory
()
let
average
=
await
calculateAverageTemperature
(
for
:
records
)
let
response
=
await
upload
(
result
:
average
)
(
“Server response:
\(
response
)
”
)
}
如你所見,所有的閉包和縮排都不見了,這使得程式碼的形式變成所謂的“直行程式碼” —— 除去
await
關鍵字,它們看起來就跟同步程式碼一樣。
對於非同步函式的工作方式,有一些很直接且特定的規則:
同步函式不能直接呼叫非同步函式 —— 這樣做不合理,因此 Swift 會丟擲錯誤
非同步函式可以呼叫其他非同步函式,但同時也可以呼叫同步函式
假設你同時有可供同步和非同步呼叫的函式,Swift 會根據當前上下文優選相應的版本 —— 如果呼叫方當前是非同步的 Swift 就會呼叫非同步的函式,否則它就會呼叫同步的函式。
上面的最後一點很重要,因為這使得庫的開發者可以同時提供同步和非同步的函式,而不必為非同步版本另行命名。
新增的
async/await
能夠完美地配合
try/catch
一起使用,這意味著非同步函式或者構造器可以按需丟擲錯誤。Swift 在這裡施加的唯一限制是關鍵字的順序,呼叫方和函式剛好是相反的。
看下面的程式碼:
enum
UserError
:
Error
{
case
invalidCount
,
dataTooLong
}
func
fetchUsers
(
count
:
Int
)
async
throws
->
[
String
]
{
if
count
>
3
{
throw
UserError
。
invalidCount
}
return
Array
([
“Antoni”
,
“Karamo”
,
“Tan”
]。
prefix
(
count
))
}
func
save
(
users
:
[
String
])
async
throws
->
String
{
let
savedUsers
=
users
。
joined
(
separator
:
“,”
)
if
savedUsers
。
count
>
32
{
throw
UserError
。
dataTooLong
}
else
{
return
“Saved
\(
savedUsers
)
!”
}
}
func
updateUsers
()
async
{
do
{
let
users
=
try
await
fetchUsers
(
count
:
3
)
let
result
=
try
await
save
(
users
:
users
)
(
result
)
}
catch
{
(
“Oops!”
)
}
}
我們可以看到,定義可丟擲錯誤的非同步函式的關鍵字順序是
async throws
,而呼叫方則需要寫作
try await
。
async/await:非同步序列
SE-0298 引入了一個新的協議:
AsyncSequence
,用於遍歷非同步的序列。
AsyncSequence
的使用方式跟
Sequence
幾乎一致,除了你需要讓實現的型別遵守
AsyncSequence
和
AsyncIterator
,並且
next
方法必須以
async
標記。當迭代推進到序列的末尾時,
next()
要返回
nil
,這和
Sequence
是一樣的。
舉個例子,我們實現一個
DoubleGenerator
,從 1 開始,每次被呼叫時返回前一個數值的兩倍:
struct
DoubleGenerator
:
AsyncSequence
{
typealias
Element
=
Int
struct
AsyncIterator
:
AsyncIteratorProtocol
{
var
current
=
1
mutating
func
next
()
async
->
Int
?
{
defer
{
current
&*=
2
}
if
current
<
0
{
return
nil
}
else
{
return
current
}
}
}
func
makeAsyncIterator
()
->
AsyncIterator
{
AsyncIterator
()
}
}
提示:
如果你把上面程式碼中的
async
都移除,你相當於擁有了一個完成相同事情的
Sequence
—— 所以說兩種序列很相似。當然,相應的協議約束也要改變
一旦我們有了非同步序列,我們就可以用
for await
語句在非同步上下文中遍歷它,像下面這樣:
func
printAllDoubles
()
async
{
for
await
number
in
DoubleGenerator
()
{
(
number
)
}
}
AsyncSequence
協議還提供了許多常見方法的預設實現,包括
map()
,
compactMap()
,
allSatisfy()
等。 例如,我們可以用
contains
來檢查生成器是否包含特定數字:
func
containsExactNumber
()
async
{
let
doubles
=
DoubleGenerator
()
let
match
=
await
doubles
。
contains
(
16_777_216
)
(
match
)
}
當然,這些方法都需要在非同步上下文中使用。
更高效的只讀屬性
SE-0310 升級了 Swift 的只讀屬性,以支援
async
和
throws
關鍵字(可以單獨或者同時使用)。
舉個例子,我們建立一個
BundleFile
struct 來載入一個檔案的內容,可能遇到檔案不存在、檔案內容無法讀取、或者內容太大讀取時間很長等等情況。我們可以像下面這樣標記
contents
屬性為
async throws
:
enum
FileError
:
Error
{
case
missing
,
unreadable
}
struct
BundleFile
{
let
filename
:
String
var
contents
:
String
{
get
async
throws
{
guard
let
url
=
Bundle
。
main
。
url
(
forResource
:
filename
,
withExtension
:
nil
)
else
{
throw
FileError
。
missing
}
do
{
return
try
String
(
contentsOf
:
url
)
}
catch
{
throw
FileError
。
unreadable
}
}
}
}
因為
contents
同時是非同步和可丟擲錯誤的,我們必須使用
try await
來讀取:
func
printHighScores
()
async
throws
{
let
file
=
BundleFile
(
filename
:
“highscores”
)
try
await
(
file
。
contents
)
}
結構化併發
SE-0304 引入了一整套執行、取消以及監控併發的操作,這些是基於
async/await
關鍵字和非同步序列。
出於演示的目的,我們引入下面這兩個函式 —— 一個模擬從特定地點拉取天氣指數的非同步函式,一個獲取斐波那契數列指定位置上的數字的同步函式。
enum
LocationError
:
Error
{
case
unknown
}
func
getWeatherReadings
(
for
location
:
String
)
async
throws
->
[
Double
]
{
switch
location
{
case
“London”
:
return
(
1。
。。
100
)。
map
{
_
in
Double
。
random
(
in
:
6。
。。
26
)
}
case
“Rome”
:
return
(
1。
。。
100
)。
map
{
_
in
Double
。
random
(
in
:
10。
。。
32
)
}
case
“San Francisco”
:
return
(
1。
。。
100
)。
map
{
_
in
Double
。
random
(
in
:
12。
。。
20
)
}
default
:
throw
LocationError
。
unknown
}
}
func
fibonacci
(
of
number
:
Int
)
->
Int
{
var
first
=
0
var
second
=
1
for
_
in
0。
。<
number
{
let
previous
=
first
first
=
second
second
=
previous
+
first
}
return
first
}
結構化併發主要的變化是引入了
Task
和
TaskGroup
這兩個新的型別,它們可以讓我們以獨立或者協同的方式執行併發操作。
最簡單的使用形式是建立一個
Task
物件,然後把希望執行的非同步操作傳給它。這會立即啟動一個非同步執行緒來執行,而我們可以用
await
來等待結果完成。
比如,我們可以在後臺執行緒多次呼叫
fibonacci(of:)
,以取代序列中的前50個數字:
func
printFibonacciSequence
()
async
{
let
task1
=
Task
{
()
->
[
Int
]
in
var
numbers
=
[
Int
]()
for
i
in
0。
。<
50
{
let
result
=
fibonacci
(
of
:
i
)
numbers
。
append
(
result
)
}
return
numbers
}
let
result1
=
await
task1
。
value
(
“斐波那契數列中的前50個數字:
\(
result1
)
”
)
}
如你所見,我顯式編寫了
Task { () -> [Int] in }
以便 Swift 知道任務會返回。我們也可以利用型別推斷和
map
函式,寫出下面這樣更極簡的程式碼:
let
task1
=
Task
{
(
0。
。<
50
)。
map
(
fibonacci
)
}
再次強調,任務一經建立就會開始執行。
printFibonacciSequence()
會在其所處的執行緒上繼續往下執行,同時斐波那契數列的數字也被計算。
提示:
我們的任務操作是一個非逃逸閉包,因為任務是即時執行。因此當你在一個類或者結構體中使用
Task
時,你並不需要使用
self
來訪問屬性或者方法。
當我們要讀取完成的數字時,
await task1。value
能夠確保
printFibonacciSequence()
暫定住,直到任務完成輸出就緒。假設你並不需要關心任務的返回結果 —— 只需要任務啟動,任其自行結束 —— 那麼你並不需要儲存任務。
對於會丟擲未捕獲錯誤的任務操作,讀取任務的
value
屬性也會自動丟擲這些錯誤。因此,我們可以程式碼中同時編寫多個任務,等待它們全部完成:
func
runMultipleCalculations
()
async
throws
{
let
task1
=
Task
{
(
0。
。<
50
)。
map
(
fibonacci
)
}
let
task2
=
Task
{
try
await
getWeatherReadings
(
for
:
“Rome”
)
}
let
result1
=
await
task1
。
value
let
result2
=
try
await
task2
。
value
(
“斐波那契數列中的前50個數字:
\(
result1
)
”
)
(
“羅馬的天氣指數:
\(
result2
)
”
)
}
Swift 為我們提供了
high
,
default
,
low
以及
background
幾種內建的任務優先順序,可以透過在建立任務時由構造器
Task(priority: 。high)
來定製。如果僅針對蘋果的平臺,還可以使用我們更為熟悉的
userInitiated
代替
hight
,使用
utility
代替
low
,但
userInteractive
是保留給主執行緒使用的。
除了執行操作,
Task
還為我們提供了一些靜態方法以便控制程式碼的執行:
呼叫
Task。sleep()
會導致當前任務休眠指定納秒的時間,所以,要指定 1秒,引數要提供 1_000_000_000
呼叫
Task。checkCancellation()
會檢查是否有人透過
cancel()
方法取消了任務,如果有則會丟擲一個
CancellationError
。
呼叫
Task。yield()
會掛起當前任務一段時間,以便讓出時間片給其他正在等待的任務。這個 API 是很重要,尤其是在你在一個迴圈中執行開銷非常昂貴的工作時。
我們可以用下面的程式碼來理解上面幾種操作:
func
cancelSleepingTask
()
async
{
let
task
=
Task
{
()
->
String
in
(
“Starting”
)
await
Task
。
sleep
(
1_000_000_000
)
try
Task
。
checkCancellation
()
return
“Done”
}
// 任務已經開始,但我們趁它處於休眠時把它取消掉
task
。
cancel
()
do
{
let
result
=
try
await
task
。
value
(
“結果:
\(
result
)
”
)
}
catch
{
(
“任務已經被取消”
)
}
}
在上面的程式碼中,
Task。checkCancellation()
會發現任務已經被取消,於是立即丟擲
CancellationError
,但這個錯誤並不會馬上來到我們面前,知道我們嘗試讀取
task。value
。
提示:
我們可以使用
task。result
來獲取一個
Result
的值,它包含了任務成功或者失敗的值。比如,上面的程式碼我們會獲得
Result
。這就不要求
try
語句了,因為我們需要自行處理成功和失敗的情況。
為了避免篇幅過長,Swift 5。5 的新特性介紹將拆分為兩篇文章來完成。
我的公眾號 這裡有Swift及計算機程式設計的相關文章,以及優秀國外文章翻譯,歡迎關注~