Swift基础

通过可运行程序掌握Swift的静态类型、不可变与可变绑定、安全转换、控制流、函数、集合和值语义。

难度 入门 时长 标准深度约10分钟
版本 Swift 6.3.3
what

Swift是静态类型语言:每个值都有类型,编译器会检查值如何经过声明、表达式、函数和集合。

trap

类型推断不会执行隐式数值转换,也不会让不安全的输入自动变得有效;数组下标和强制解包仍可能在运行时触发错误。

fix

优先使用let,在明确的边界执行转换,用if let或guard let验证可选结果,并根据顺序与唯一性需求选择集合。

是什么,为什么存在

Swift的基础规则把名称、值、类型和控制流连接起来。 声明使用let或var绑定名称,绑定的类型决定允许哪些操作。 函数封装这些操作,数组、集合与字典则组织一组带类型的值。

Swift是静态强类型语言。 程序运行前,编译器就知道每个表达式的类型,并拒绝不匹配的操作,例如直接把Int与Double相加。 这能在接近错误源头的位置发现大量问题,但无法证明集合索引一定有效,也无法证明可选值一定包含内容。

大多数声明使用 类型推断(type inference) 。 在let retryLimit = 3中,整数字面量及其上下文让编译器推断出Int,但该声明仍然只有一种固定类型。 如果初始化值不能清楚表达预期类型,或API契约应直接可见,可以写出let timeout: Double = 3这样的类型标注。

不会再次赋值的绑定使用let,算法确实需要重新赋值时才使用var。 这一区分能让代码审查者直接看到可变位置。 不过,它描述的是绑定本身,并不表示通过类引用可达的每个对象都具有深层不可变性。

从命令行脚本到应用程序,每段Swift代码都会用到这些规则。 输入以文本或可选数据的形式进入,代码对其转换和验证,控制流选择执行路径,带类型的集合再把结果交给下一层。

工作原理

Swift声明在被读取前必须具有名称、类型和值。 初始化器既可以提供值,也可以提供足够的类型推断上下文。 没有初始化器的声明需要显式类型,而且所有使用路径都必须先完成初始化;只要编译器能证明之后恰好赋值一次,延后给let赋值也是合法的。

常见标量类型包括Int、Double、Bool、String与Character。 整数类型在固定边界内表示整数,浮点类型近似表示实数,条件则直接接受Bool。 Swift不会把0、空字符串或nil当作false。

数值类型不会隐式混合。 应在已经考虑单位和精度的位置构造目标类型,例如Double(itemCount) * unitPrice。 解析的失败方式不同:任意文本不一定包含有效整数,所以Int("12")返回Int?。

可选值(optional value) 写作Wrapped?,其中要么有一个包装值,要么是nil。 使用if let进行可选绑定(optional binding)时,只有值存在才会进入对应分支。 如果函数剩余部分都依赖该值,可以使用guard let,其else分支必须离开当前作用域。

Swift的主要集合类型表达了不同契约:

  • Array<Element>保存有序序列,允许重复元素。
  • Set<Element>保存唯一且可哈希的元素,不提供应用层面的顺序契约。
  • Dictionary<Key, Value>把唯一且可哈希的键关联到值;键可能不存在,因此查找结果是可选值。

同一集合中的所有元素都具有声明的元素类型。 字面量的上下文通常可以提供该类型,例如let ports: [Int] = [80, 443]。 空字面量需要上下文:空数组写作[String](),空集合写作Set<String>(),空字典则写作[String: Int](),或在有类型标注时写作[:]。

条件和循环把各项操作组合起来。 if与switch选择路径,for-in遍历序列,while则在布尔条件为true时重复执行。 switch必须穷尽,因此每个可能输入都要进入一个分支或有意设置的default。

函数为一项计算提供带类型的名称。 参数与返回值都属于函数类型的一部分,除非声明为inout,否则参数是常量。 Swift默认使用实参标签;在参数名称前写_可以让调用点省略标签。

编译器会从内向外检查这套模型。 字面量与周围上下文确定类型,运算符约束操作数,函数调用则必须匹配参数和结果类型。 依赖数据的边界仍需运行时检查,例如解析、整数运算、集合索引与可选值解包。

示例

绑定值并作出判断

这个结账程序用整数分表示金额,避免意外混合整数数量与浮点价格。 局部类型都由编译器推断。 只有subtotal使用var,因为折扣分支会重新给它赋值。

checkout.swift
// # not executed here: Swift toolchain is not installed.
let unitPrice = 125
let quantity = 3
var subtotal = unitPrice * quantity
let hasMemberDiscount = true

if hasMemberDiscount {
    subtotal -= 25
}

let shipping = subtotal >= 300 ? 0 : 40
let total = subtotal + shipping

print("items: \(quantity)")
print("subtotal: \(subtotal)")
print("shipping: \(shipping)")
print("total: \(total)")
Not executed here: Swift toolchain is not installed.

三元表达式从两个分支中为shipping选择一个值,所以该绑定可以保持为常量。 所有运算都使用Int,名称也保留了单位决策。 生产环境中的金额应使用整数最小货币单位或领域类型,而不是含义不明的Double。

解析边界数据而不强制解包

字典查找与整数解析都会返回可选值。 parseSeats(_:)通过一个guard依次要求字段存在、可以解析且位于允许范围内,然后才返回普通Int。

seat_requests.swift
// # not executed here: Swift toolchain is not installed.
func parseSeats(_ fields: [String: String]) -> Int? {
    guard
        let rawSeats = fields["seats"],
        let seats = Int(rawSeats),
        (1...8).contains(seats)
    else {
        return nil
    }

    return seats
}

let requests = [
    ["name": "Mina", "seats": "4"],
    ["name": "Noah", "seats": "many"],
    ["name": "Iris"],
]

for request in requests {
    let name = request["name", default: "anonymous"]
    if let seats = parseSeats(request) {
        print("\(name): \(seats)")
    } else {
        print("\(name): invalid")
    }
}
Not executed here: Swift toolchain is not installed.

调用方无法区分字段缺失、格式错误与超出范围,因为这个小函数有意把三种情况都映射为nil。 如果调用方需要不同的恢复或诊断信息,应使用结果类型或可抛出函数。 因此,无效输入不会作为数值人数进入预订计算。

按契约选择集合

数组保留标签的输入顺序和重复项。 集合去除重复项,随后sorted()为显示创建顺序可预测的数组。 字典为每个唯一标签保存一个派生值。

tag_summary.swift
// # not executed here: Swift toolchain is not installed.
let rawTags = ["swift", "ios", "swift", "testing", "ios"]
let uniqueTags = Set(rawTags)
let displayTags = uniqueTags.sorted()

let lengths = Dictionary(
    uniqueKeysWithValues: displayTags.map { tag in
        (tag, tag.count)
    }
)

for tag in displayTags {
    print("\(tag): \(lengths[tag, default: 0])")
}

let longTags = displayTags.filter { $0.count >= 5 }
print("long: \(longTags.joined(separator: ", "))")
Not executed here: Swift toolchain is not installed.

显式排序属于输出契约;直接遍历uniqueTags无法为调用方提供有意义的顺序。 Dictionary(uniqueKeysWithValues:)要求每个键都唯一。 这里的键值对来自集合,因此满足该前置条件;生成代码却经常把这个初始化器直接用于未经检查的输入,并在遇到重复键时触发错误。

观察值语义

Swift数组和字典具有 值语义(value semantics) 。 函数可以把输入集合复制到局部变量,只修改这个局部值,然后将其返回,而不改变调用方的原始值。

collection_copies.swift
// # not executed here: Swift toolchain is not installed.
func addingPriority(to jobs: [String]) -> [String] {
    var result = jobs
    result.insert("incident", at: 0)
    return result
}

let queuedJobs = ["backup", "report"]
let priorityJobs = addingPriority(to: queuedJobs)

print("queued:", queuedJobs.joined(separator: ", "))
print("priority:", priorityJobs.joined(separator: ", "))

var profile = ["theme": "light"]
let savedProfile = profile
profile["theme"] = "dark"

print("current:", profile["theme", default: "missing"])
print("saved:", savedProfile["theme", default: "missing"])
Not executed here: Swift toolchain is not installed.

虽然result最初包含相同元素,queuedJobs仍然没有改变。 profile发生修改后,profile与savedProfile也遵循相同的分离规则。 Swift标准集合可能在写入前共享内部存储,但这种优化不会改变可以观察到的值行为。

陷阱

修复方法: 为每个声明选择一种稳定类型。 如果字面量推断可能掩盖预期表示,应添加显式标注;传入值则在命名清楚的边界完成转换,不要让强制转换散落在算法中。

修复方法: 先确定哪种类型负责这项计算,验证来源后再集中转换。 转换必须精确保留值时使用Int(exactly:),并在标识符中明确保留单位,例如priceInCents。

修复方法: 任务允许时优先使用遍历、first或last。 确实要用外部整数选择元素时,先检查array.indices.contains(index);在边界检查之前,还要先解包解析与字典查找结果。

修复方法: 只要顺序可被观察,就要显式排序键或值,包括快照、命令输出、有顺序要求的编码数据和测试。 如果真正需要的是唯一性或查找能力,则仍应保留无序集合。

修复方法: 遍历Character值,或使用String.Index操作。 只有协议确实要求码元时,才选择utf8之类的正确底层视图;不要把字节数当作显示字符数。

深入 值语义可以被观察

值语义可以被观察

Swift标准库中的String、Array、Set与Dictionary都是值。 赋值、传参和返回会产生逻辑上独立的值,所以修改一个变量不会修改从它复制出的另一个值。 用户定义的结构体与枚举也遵循这套模型,不过值内部仍可能包含指向共享类实例的引用。

这是接口保证,并不表示每次赋值都会立即复制每个字节。 应从可观察的修改来理解:如果let snapshot = current捕获了一份集合值,之后对current的结构性修改不会改变snapshot。 如果两个集合包含相同的可变类实例,对这些实例内部的修改仍然共享,因为元素本身是引用。

函数签名默认采用这套模型。 集合参数是传入函数的值,参数名称不能被重新赋值,也不能直接修改。 可以把它复制到局部var后返回新值;如果API有意修改调用方的存储,而且调用点能清楚看出这种所有权,也可以使用inout。

写时复制是一种优化

标准库值类型可以使用 写时复制(copy-on-write) ,在没有修改需要独占缓冲区时共享存储。 发生修改时,实现会在必要时让存储变为独占,从而保留值语义。 因此,即使第四个示例中的集合在写入前共享后备存储,其行为也如同彼此独立。

不要把写时复制当作同步机制。 两个任务修改同一个变量仍会形成共享可变状态,缓冲区是否共享之类的实现细节也不是API保证。 在对真实工作负载进行测量之前,不应提出性能结论,也不应把清楚的值改成手动引用存储。

嵌套值还需要检查元素本身。 复制结构体数组后再修改,会得到独立的元素值;复制类引用数组时,复制的是引用。 在声称快照已经隔离之前,应审查完整对象图。

字符串索引遵循字符边界

Swift Character表示一个扩展 字素簇(grapheme cluster) ,其中可以包含一个或多个Unicode标量。 与字节遍历相比,这套模型能让迭代更接近用户感知的字符。 这也意味着任意整数偏移与字符边界之间不存在固定映射。

String.Index记录某个字符串视图中的有效位置。 应通过字符串自身的startIndex、endIndex、index(after:)或index(_:offsetBy:)等操作取得索引;再次使用旧位置或外部位置前要先验证。 移动到较远字符所需的工作可能与距离成正比,因此反复使用偏移索引并不适合完整扫描。

应根据正在实现的边界选择视图。 面向文本的行为使用Character迭代,需要区分标量时使用unicodeScalars,面向字节的格式则使用utf8。 规范化、与区域设置相关的比较以及显示宽度属于不同问题,单独一个count无法同时回答它们。

整数运算存在边界

Swift的定宽整数类型都有最小值和最大值。 普通运算超过这些边界时会触发错误,即使是发布构建也不会静默回绕。 应选择足以覆盖领域范围的类型,在计算前验证不可信操作数,并把多个最大允许值放在一起测试,而不是逐个测试。

溢出运算符&+、&-与&*会有意回绕。 它们只适合契约本来就是模运算的算法,不应当用来修补原因不明的溢出。 生成代码有时会替换成这些运算符来阻止崩溃,却把无效交易悄悄变成看似有效的总额。

范围同样编码了边界选择。 1...8包含两个端点,1..<8则不包含上界。 空范围或反向范围需要谨慎处理;集合切片应从该集合自身取得边界,而不是假定每种集合都能使用从零开始的整数位置。

可选值的默认值是一项策略

只有左侧可选值为nil时,空合运算符才会对右侧求值。 如果缺失确实表示某个领域默认值,这种写法很简洁,例如把缺失的显示昵称设为"Anonymous"。 如果nil同时覆盖格式错误或输入被拒绝,而后备值又看似成功,这种写法就会造成误导。

可选链在遇到第一个缺失环节时停止,并让整体结果变为可选值。 如果一次读取允许把缺失继续向外传递,这种行为很有用。 如果每个缺失环节都需要不同消息,可选链就不那么合适,因为最后的nil已经无法说明哪项假设失败。

在拥有后备决策的层级之前,应保留值缺失的原因。 小型解析器可以有意对所有无效格式返回nil;请求边界则可能需要错误类型,以区分字段缺失、语法错误和值不允许。 类型应当保留调用方所需的信息,不多也不少。

作用域与文件决定名称范围

大括号会为函数、循环与分支创建局部作用域。 声明可以遮蔽外层名称,但作用域嵌套后,反复使用value或result之类的宽泛名称会让日志与审查难以理解。 尤其在转换边界处,名称应保留领域含义与单位。

Swift允许在文件作用域声明,命令行源文件也能执行顶层语句。 较大的程序通常把边界工作放在入口点附近,把计算移入具有明确参数和结果类型的函数。 这样就能独立测试格式错误的输入,不必连同进程启动和控制台输出一起测试。

访问控制也是声明契约的一部分。 只服务于一个文件的辅助声明可以使用private或fileprivate,面向模块的声明则可以使用Swift默认的internal。 不要只因为生成代码把所有辅助项放在顶层,就对外暴露这些声明。

按以下顺序审查一份基础Swift文件:

  1. 找出每个外部字符串、可选字段、数值单位以及对集合顺序的假设。
  2. 检查每个绑定是否具有预期静态类型,并且只开放必要的可变性。
  3. 沿着每种无效输入跟踪到明确的拒绝、后备或向外传播的失败。
  4. 检查可观察输出是否具有确定顺序,并遵循正确的Unicode边界。

编译器会证明类型兼容性、确定初始化与switch穷尽性。 哪些输入可信、哪些默认值真实、哪些修改属于共享状态,仍要由作者决定。 这些决策在源码中清楚可见后,编译器诊断可以覆盖类型错误,测试则可以覆盖剩余的依赖数据路径。

延伸阅读

检查点

4个问题 · 1道输出预测题 · 1道找错题

复制为Markdown 面试题库 在GitHub上编辑 报告错误 讲清楚了吗?