F# 使用牛津逗号连接字符串

3
我希望你能用牛津(或串联)逗号将一组字符串合并为一个字符串。
给定:
let ss = [ "a"; "b"; "c"; "d" ]

I want

"a, b, c, and d"

下面是我的想法。

let oxford (strings: seq<string>) =
  let ss = Seq.toArray strings
  match ss.Length with
  | 0 -> ""
  | 1 -> ss.[0]
  | 2 -> sprintf "%s and %s" ss.[0] ss.[1]
  | _ ->
    let allButLast = ss.[0 .. ss.Length - 2]
    let commaSeparated = System.String.Join(", ", allButLast)
    sprintf "%s, and %s" commaSeparated (Seq.last ss)

这怎么能够改进呢?
--- 编辑 --- 关于多次迭代序列的评论是正确的。下面这两个实现都避免了转换为数组。
如果我使用“seq”,我非常喜欢这个:
open System.Linq
let oxfordSeq (ss: seq<string>) =
  match ss.Count() with
  | 0 -> ""
  | 1 -> ss.First()
  | 2 -> sprintf "%s and %s" (ss.ElementAt(0)) (ss.ElementAt(1))
  | _ ->
    let allButLast = ss.Take(ss.Count() - 1)
    let commaSeparated = System.String.Join(", ", allButLast)
    sprintf "%s, and %s" commaSeparated (ss.Last())

如果我使用array,还可以利用索引避免对Last()的迭代。

let oxfordArray (ss: string[]) =
  match ss.Length with
  | 0 -> ""
  | 1 -> ss.[0]
  | 2 -> sprintf "%s and %s" ss.[0] ss.[1]
  | _ ->
    let allButLast = ss.[0 .. ss.Length - 2]
    let commaSeparated = System.String.Join(", ", allButLast)
    sprintf "%s, and %s" commaSeparated (ss.[ss.Length - 1]

--- 编辑 ---
看到@CaringDev提供的链接,我认为这很不错。没有通配符,可以处理null值,在正确获取索引方面比较简单,并且Join()方法只需遍历数组一次。

let oxford = function
    | null | [||] -> ""
    | [| a |] -> a
    | [| a; b |] -> sprintf "%s and %s" a b
    | ss ->
        let allButLast = System.ArraySegment(ss, 0, ss.Length - 1)
        let sb = System.Text.StringBuilder()
        System.String.Join(", ", allButLast) |> sb.Append |> ignore
        ", and " + ss.[ss.Length - 1] |> sb.Append |> ignore
        string sb

这个也相当不错,甚至跳跃更少:

这个也相当不错,甚至跳跃更少:

let oxford2 = function
    | null | [||] -> ""
    | [| a |] -> a
    | [| a; b |] -> sprintf "%s and %s" a b
    | ss ->
        let sb = System.Text.StringBuilder()
        let action i (s: string) : unit = 
            if i < ss.Length - 1 
            then 
                sb.Append s |> ignore
                sb.Append ", " |> ignore
            else 
                sb.Append "and " |> ignore
                sb.Append s |> ignore
        Array.iteri action ss          
        string sb

2
有什么需要改进的吗? - scrwtp
我也在想是否有更好的方法来获取allButLast。 - Brett Rowberry
1
这个更适合放在[codereview.se]上。 - glennsl
好的,最后一条评论,就分配而言,System.ArraySegment<string>(ss, 0, ss.Length - 1) 是最佳选择吗? ss.[0 .. ss.Length - 2] 可能是最糟糕的。 Span<T> 在这里也有用吗? - Brett Rowberry
1
感兴趣吗?!https://dev59.com/qq_la4cB1Zd3GeqPtn_6 - CaringDev
显示剩余2条评论
5个回答

5

您可以直接查看列表,并对列表使用模式匹配。也许这个方法还可以改进,但这已经能传达出基本思路。

let rec oxford (s:string) (ss:string list) =
    match ss with
    | [] -> s
    | [x;y] -> sprintf "%s, %s, and %s" s x y
    | h::t when String.length s = 0 -> oxford h t
    | h::t -> oxford (sprintf "%s, %s" s h) t

它会使用逗号,对更小的列表进行递归调用自身。当列表仅有两个元素时,会使用 and 替代逗号。由于第一次调用是空字符串,when 语句非常不幸,但我发现需要这样做以避免出现前导逗号。

编辑

因此,我个人更喜欢上面的选项来处理少量单词。但是,对于大量单词,每次调用都进行字符串拼接的性能并不好。

// collect into a list including the *,* and *and*, then just concat that to string
let oxfordDrct (ss:string list) =
    let l = ss |> List.length
    let map i s = if(i < l-1) then [s;", "] else ["and ";s]        
    match ss with
    | [] -> ""
    | [x] -> x
    | [x;y] -> sprintf "%s, and %s" x y
    | _ -> ss |> List.mapi map |> List.concat |> String.concat ""

// Recursive like the original but instead pass a StringBuilder instead of string
let oxfordSb xs =        
    let rec collect (s:StringBuilder) (ss:string list) =
        match ss with
        | [] -> s
        | [x;y] -> sprintf ", %s, and %s" x y |> s.Append
        | h::t when s.Length = 0 -> collect (s.Append(h)) t
        | h::t -> collect (s.Append(sprintf ", %s" h)) t
    let sb = new StringBuilder()     
    (collect sb xs) |> string

这两个选项的表现与原始选项相似,所有这些选项都比 rec 通过 string 更好。

非常有趣的答案。我通常不会考虑递归。我喜欢我的答案是通过接受一个seq(IEnumerable)使其通用,因此它适用于列表、数组、seq等。我开始使用列表模式匹配,但决定不想被限制只接受列表。我可以接受一个seq并将其转换为列表。 - Brett Rowberry
你为什么决定接受一个字符串和一个字符串列表,而不是只有一个字符串列表? - Brett Rowberry
1
@BrettRowberry 对于递归函数,你需要一种方法来收集结果并随着累积。在FP中,关于rec更为常见。同样重要的是,某些东西有多容易理解。 - Devon Burriss

3

foldBack也是可能的选择。使用这种方式连接字符串的性能不是很好,但对于4个项目来说通常并不重要。

let oxfordify (ws : seq<string>) : string =
  // Folder concats the value and the aggregated result using seperator 0
  //  it updates the state with the new string and moves 
  //  seperator 1 into seperator 0 slot and set seperator 1 to ", "
  let folder v (r, s0, s1) = (v + s0 + r, s1, ", ")
  // The seperator 0 for first iteration is empty string (if it's only 1 value)
  // The seperator 1 is set to ", and " as the seperator between 2 last items
  // For all other items ", " will be used (see folder)
  let r, _, _ = Seq.foldBack folder ws ("", "", ", and ")
  r

我的同事向我指出使用无限序列来表示分隔符:

let separators = 
  Seq.concat [| [|""; ", and "|] :> seq<_>; Seq.initInfinite (fun _ -> ", ") |]
let oxfordify (ws : seq<string>) : string = 
  Seq.fold2 (fun r v s -> v + s + r) "" (ws |> Seq.rev) separators 

如果您想要更高效的选项,可以考虑这样做:

module Details =
  module Loops =
    let inline app (sb : System.Text.StringBuilder) (w : string) : unit =
      sb.Append w |> ignore
    let rec oxfordify sb (ws : _ array) i : string =
      if i < ws.Length then
        if i = 0 then 
          ()
        elif i = ws.Length - 1 then 
          app sb ", and "
        else 
          app sb ", "
        app sb ws.[i]
        oxfordify sb ws (i + 1)
      else
        sb.ToString ()
open Details

let oxfordify (ws : string array) : string = 
  let sb = System.Text.StringBuilder ()
  Loops.oxfordify sb ws 0

2
不错。简洁明了,使用可用的库函数。我想到了这个,但递归对我来说更容易理解。我想底层实现与递归相同,因为递归性能不佳。 - Devon Burriss
1
谢谢大家!有人说StackOverflow不友好,但F#er是一群特别的人 :-) - Brett Rowberry

2
我的方法并不是与众不同的。
let oxford (ss: string array) =
    match ss.Length with
    | 0 -> ""
    | 1 -> ss.[0]
    | 2 -> sprintf "%s and %s" ss.[0] ss.[1]
    | _ ->
       let cs = System.String.Join(", ", ss.[ 0 .. ss.Length - 2])
       sprintf "%s, and %s" cs (ss.[ss.Length - 1])

我认为你的代码没有任何问题,只是你需要对序列进行多次迭代(数组转换+字符串连接+Seq.last)。
这不是一个性能问题,因为我不希望这个函数被用于超过一位数的大规模序列,但如果序列具有副作用或计算成本很高,你会得到奇怪的行为。这就是我将输入切换为数组的原因。
至于可读性,你已经做到了最好,明确枚举基本情况,并且最后一行中使用 sprintf 多出来的字符串分配无关紧要(特别是与直接递归相比) 。

非常好。ss.[0 .. ss.Length - 2] 分配了吗? - Brett Rowberry
1
是的,数组的splice操作符会分配一个新的数组。为了避免这种情况,您可以使用截断的seq:Seq.take (ss.Length - 1) ss或者使用数组段:System.ArraySegment(ss, 0, ss.Length - 1) - Tarmil

1
我曾未见过的一种方式:匹配列表末尾。
let ox =
    List.rev >> function
    | [] -> ""
    | [x] -> x
    | [y; x] -> x + " and " + y
    | y::ys -> String.concat ", " (List.rev ("and " + y::ys))
// val ox : (string list -> string)

ox["a"; "b"; "c"; "d"]
// val it : string = "a, b, c, and d"

0

另一种递归变体:

let rec oxford l =
    match l with
    | [] -> ""
    | [x] -> x
    | [x; y] -> x + " and " + y
    | head :: tail -> 
        head + ", " + oxford tail

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接