Swift中的命令行rools:Words

swift 教程
2021-01-25 10:35:43
8 0 0

我喜欢偶尔编写自定义命令行工具。最近的一些例子是walkstest。

不久前我读到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-写为“从第三个开始的每个单词”。

对于每个范围,我们使用1Int.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标准库有一些关于它们的基本规定),所以把这个比喻为一个巨大的盐粒。

作者介绍

用微信扫一扫

收藏