发布于:2021-01-25 10:35:43
0
86
0
我喜欢偶尔编写自定义命令行工具。最近的一些例子是walk
和stest。
不久前我读到Rob Landley写一个words
工具的想法,因为我喜欢这个想法,我想(重新)实现这个工具,同时试着看看我是否能保持它比Rob的C版本更简单。
Swift
阅读/打印
Words
应该是一个过滤器,这意味着它应该从标准输入读取并写入标准输出。让我们开始编写一个最简单的过滤器,它只回显stdin而不做任何修改。
while let line = readline() {
print(line)
}
拆分/联接
要解决手头的问题,我们可以将每行处理为
将行拆分为单词
选择其中的一些单词
按任意顺序
允许多个选择
将所选单词组合成新行
让我们实现更简单的部分其中:
while let line = readLine() {
let words = line.components(separatedBy: .whitespaces)
print(words.joined(separator: " "))
}
不同之处在于,这个版本用单空格字符替换了空格序列。
透明/不透明
需要选择哪些单词(及其顺序)
由用户以某种方式指示
由程序以某种方式评估
稍后我们将处理用户界面。程序可以将所需的信息存储为(被动)数据结构或(主动)函数或对象。
这说明了一个典型的设计冲突…嗯,不是函数式编程和面向对象编程之间的冲突,我相信这是完全独立于此的。我指的是Noel Welsh所描述的“不透明和透明的口译员”。
在我们的程序中,差异如下所示:
透明
let wanted: [Int] = // TODO
let words = line.components(separatedBy: .whitespaces)
let selected = select(wanted, from: words)
print(selected.joined(separator: " "))
不透明
let select: ([String]) -> [String] = // TODO
let words = line.components(separatedBy: .whitespaces)
let selected = select(words)
print(selected.joined(separator: " "))
取舍可以归结为一个问题:我们想把解决方案的哪些部分分开?
我们先来看看透明的变体,看看它会带我们去哪里。
论据
我们要在命令行中输入选择。我可以想到两种方法:
每个命令行参数表示一个选择,程序仅对标准输入进行操作。
words 3 4 2 < file
第一个命令行参数指定所有选择,其余参数指示要处理的文件。
words 3,4,2 file
我们将使用第一种方法,因为我相信它更容易实现
func index(_ string: String) -> Int {
guard let int = Int(string) else {
print("invalid index: (string)")
exit(EX_USAGE)
}
return int - 1
}
let arguments = CommandLine.arguments.dropFirst()
let wanted = arguments.map(index)
这里发生了很多事情,让我们来看看代码:
let arguments = CommandLine.arguments.dropFirst()
let wanted = arguments.map(index)
CommandLine.arguments
的第一个元素包含可执行文件的路径,我们将把剩余的参数转换成索引
guard let int = Int(string) else {
print("invalid index: (string)")
exit(EX_USAGE)
}
我们把string
转换成一个数字。如果失败,比如当string
不包含数字时,我们打印一条错误消息,并使用EX_USAGE
中止。
return int - 1
命令行上提供的索引应该从1开始,因此我们在这里按1更正以获得数组索引。
选择
一旦我们有了索引,我们就可以从数组中获取相应的元素。
func select(_ indices: [Int], from array: [A]) -> [A] {
return indices.map { array[$0] }
}
“致命错误:索引超出范围”
只要有一个索引超出了anyline的范围,这个简单版本的程序就会崩溃。我们可以通过忽略超出给定行界限的索引来防止这个问题。
func select(_ indices: [Int], from array: [A]) -> [A] {
let valid = { array.indices.contains($0) }
return indices.filter(valid).map { array[$0] }
}
射程
一个非常有用的特性是,除了数字之外,还可以提供数字的范围,如-f 3-6
以打印第三到第六个字段。
我们可以用Range
类型对这些范围进行建模,所以让我们尝试将该特性添加到我们的程序中。
同样,更复杂的部分是解析索引:
func index(_ string: String) -> CountableRange{
func parse(_ component: String, default empty: Int) -> Int {
if (component.isEmpty) {
return empty
}
if let int = Int(component) {
return int
}
print("invalid component: `(component)' in range: `(string)'")
exit(EX_USAGE)
}
let components = string.components(separatedBy: "-")
let lower = parse(components.first!, default: 1) - 1
let upper = parse(components.last!, default: Int.max)
return lower..<upper
}
我们将单个数字的解析提取到一个可以多次调用的内部函数中。作为一个内部函数,它可以访问其包含函数的参数,我们可以利用它来提供更好的错误消息。此函数还期望在缺少范围的一个边界时返回一个默认值,这意味着我们可以将3-
写为“从第三个开始的每个单词”。
对于每个范围,我们使用1
和Int.max
作为默认值,将第一个和最后一个组件分别解析为其下限和上限,并从这些边界构造一个CountableRange
。我们只需要将下限改为1,因为CountableRange
希望排除上限。
func select(_ ranges: [CountableRange], from array: [A]) -> [A] {
return ranges.flatMap { range in
return array[range.clamped(to: array.indices)]
}
}
要应用我们的选择,我们获取每个范围,使用clamp
将其修剪到数组的边界,然后从数组中获取相应的元素。因为它返回集合而不是单个单词,所以我们调用flatMap
而不是map
来展平所有这些集合。
结论
以下是整个代码:
import Foundation
func index(_ string: String) -> CountableRange{
func parse(_ component: String, default empty: Int) -> Int {
if (component.isEmpty) {
return empty
}
if let int = Int(component) {
return int
}
print("invalid component: `(component)' in range: `(string)")
exit(EX_USAGE)
}
let components = string.components(separatedBy: "-")
let lower = parse(components.first!, default: 1) - 1
let upper = parse(components.last!, default: Int.max)
return lower..<upper
}
func select(_ ranges: [CountableRange], from array: [A]) -> [A] {
return ranges.flatMap { range in
return array[range.clamped(to: array.indices)]
}
}
let arguments = CommandLine.arguments.dropFirst()
let wanted = arguments.map(index)
while let line = readLine() {
let words = line.components(separatedBy: .whitespaces)
let selected = select(wanted, from: words)
print(selected.joined(separator: " "))
}
在生成和使用修改后的数据的两个地方,需求的变化保持了令人愉快的局部性。我们没有修改脚本的主体,因为我们省略了更改数据的类型签名,并且在处理过程中不需要任何其他更改。
脚本的大小与C代码相当,尽管两个版本支持不同的功能(我们的版本支持范围,而另一个版本支持自定义字分隔符),并且使用完全不同的框架(toybox基础结构用于Unix命令行工具,swift标准库有一些关于它们的基本规定),所以把这个比喻为一个巨大的盐粒。
作者介绍