告别 string.Join 的局限——用扩展方法轻松添加前缀和后缀

在日常开发中,字符串拼接恐怕是最常见的操作之一。.NET 自带的 string.Join 方法已经非常强大:性能好、支持泛型、能正确处理 null,用它来连接集合中的元素简直不要太方便。

但是,如果你写过 Java,可能会怀念 StringJoiner 类——它除了分隔符,还允许你指定前缀后缀。例如 new StringJoiner(", ", "[", "]").add("A").add("B") 可以得到 "[A, B]"。这种写法在生成日志、SQL 片段、JSON 数组字符串等场景下非常实用。

那么,在 .NET 中能否也拥有这样的能力?当然可以。今天我们就来自己动手,为 IEnumerable<T> 写一个扩展方法,让它支持前缀和后缀,并且保持 string.Join 的高效和简洁。


一、原生 string.Join 的局限性

假设我们有一个水果列表:

var fruits = new List<string> { "苹果", "香蕉", "橙子" };

想要输出 "[苹果, 香蕉, 橙子]",用 string.Join 只能做到中间部分:

string middle = string.Join(", ", fruits);   // "苹果, 香蕉, 橙子"
string result = "[" + middle + "]";          // "[苹果, 香蕉, 橙子]"

虽然也不麻烦,但当拼接逻辑多次出现时,重复写前缀后缀会显得啰嗦,也容易出错。更好的方式是一次性声明“分隔符、前缀、后缀”。


二、目标 API 设计

我们希望像这样使用:

string result = fruits.JoinWithSurround(", ", "[", "]");
// 输出: [苹果, 香蕉, 橙子]
  • 第一个参数:分隔符(string
  • 第二个参数:前缀(string
  • 第三个参数:后缀(string

同时,还要支持自定义元素到字符串的转换(类似于 Select),并且处理空集合时的行为(返回 prefix + suffix,类似 Java 的 StringJoiner)。


三、实现扩展方法

我们直接编写一个静态扩展类,利用 string.Join 完成核心连接工作。

using System;
using System.Collections.Generic;
using System.Linq;

public static class StringJoinerExtensions
{
    /// <summary>
    /// 使用分隔符连接集合元素,并添加前缀和后缀。
    /// </summary>
    /// <typeparam name="T">集合元素类型</typeparam>
    /// <param name="source">要连接的集合</param>
    /// <param name="separator">分隔符(例如 ", ")</param>
    /// <param name="prefix">前缀(例如 "[")</param>
    /// <param name="suffix">后缀(例如 "]")</param>
    /// <param name="selector">可选:将元素转为字符串的函数(默认使用 ToString)</param>
    /// <returns>连接后的字符串</returns>
    public static string JoinWithSurround<T>(
        this IEnumerable<T> source,
        string separator,
        string prefix,
        string suffix,
        Func<T, string?>? selector = null)
    {
        if (source == null) throw new ArgumentNullException(nameof(source));
        if (separator == null) separator = string.Empty;
        if (prefix == null) prefix = string.Empty;
        if (suffix == null) suffix = string.Empty;

        // 空集合处理:按 Java StringJoiner 的设计,返回 prefix+suffix
        if (!source.Any())
            return prefix + suffix;

        // 将每个元素转为字符串
        IEnumerable<string> strings = selector != null
            ? source.Select(selector)
            : source.Select(x => x?.ToString() ?? string.Empty);

        // 核心连接(内部使用 StringBuilder 优化)
        string middle = string.Join(separator, strings);
        
        return prefix + middle + suffix;
    }
}

关键点说明:

  • 使用 source.Any() 判断是否为空,空时返回 prefix + suffix(如果你需要返回空字符串,可以改为 string.Empty)。
  • selector 参数让你可以自定义格式化,例如 p => $"{p.Name}({p.Age})"
  • 内部完全依赖 string.Join,因此拥有和 string.Join 一样的高性能(它会在内部预先计算总长度,一次性分配内存)。

四、使用示例

来看几个实际的例子。

4.1 基本用法

string[] fruits = { "苹果", "香蕉", "橙子" };
string result = fruits.JoinWithSurround(", ", "[", "]");
Console.WriteLine(result);   // [苹果, 香蕉, 橙子]

4.2 不同类型的集合

int[] numbers = { 1, 2, 3, 4, 5 };
string numStr = numbers.JoinWithSurround(" + ", "(", ")");
Console.WriteLine(numStr);   // (1 + 2 + 3 + 4 + 5)

4.3 自定义元素转换

var users = new[]
{
    new { Name = "张三", Age = 28 },
    new { Name = "李四", Age = 32 }
};

string userStr = users.JoinWithSurround(
    "; ",
    "用户列表:[",
    "]",
    u => $"{u.Name}({u.Age}岁)"
);
Console.WriteLine(userStr);  // 用户列表:[张三(28岁); 李四(32岁)]

4.4 处理 null 和空集合

List<string>? nullList = null;
// 会抛出 ArgumentNullException,符合预期

var emptyList = new List<int>();
string emptyResult = emptyList.JoinWithSurround(",", "[", "]");
Console.WriteLine(emptyResult);  // []   (仅前缀+后缀)

// 集合中包含 null 元素
string[] withNull = { "A", null, "C" };
string nullResult = withNull.JoinWithSurround("-", "(", ")");
Console.WriteLine(nullResult);    // (A--C)   (null 转为空字符串)

五、另一种选择:实现一个完整的 StringJoiner

如果你更喜欢面向对象的流式 API(像 Java 那样先创建对象,再逐个 Add),也可以直接写一个类:

public class StringJoiner
{
    private readonly string _separator;
    private readonly string _prefix;
    private readonly string _suffix;
    private readonly List<string> _parts = new List<string>();

    public StringJoiner(string separator, string prefix = "", string suffix = "")
    {
        _separator = separator ?? throw new ArgumentNullException(nameof(separator));
        _prefix = prefix ?? "";
        _suffix = suffix ?? "";
    }

    public StringJoiner Add(string? element)
    {
        if (element != null)
            _parts.Add(element);
        return this;
    }

    public StringJoiner Add<T>(T element)
    {
        return Add(element?.ToString());
    }

    public override string ToString()
    {
        if (_parts.Count == 0)
            return _prefix + _suffix;
        string middle = string.Join(_separator, _parts);
        return _prefix + middle + _suffix;
    }
}

// 使用
var joiner = new StringJoiner(", ", "[", "]")
    .Add("苹果")
    .Add("香蕉")
    .Add("橙子");
Console.WriteLine(joiner);   // [苹果, 香蕉, 橙子]

两者各有千秋:扩展方法更适合 LINQ 风格,直接对集合操作;而 StringJoiner 类更适合动态逐个添加元素的场景。你可以根据实际需要选择。


六、性能考量

很多人担心 Any() 会额外枚举一次集合。但实际上:

  1. 对于 ICollection<T>(如 List<T>T[]),Any() 只是检查 Count 属性,O(1) 开销。

  2. 对于纯粹的 IEnumerable<T>(如 yield return 产生的序列),Any() 只会消耗一个元素,而后面 Select + string.Join 会重新遍历整个序列,所以总共会遍历两次

    如果性能极其敏感且序列只允许单次遍历,可以考虑修改实现:先尝试将 source 转为集合(source as ICollection<T>),或者干脆放弃空集合特殊处理,直接让 string.Join 去处理空情况(它返回 string.Empty),然后手动加上前缀和后缀。但绝大多数业务场景下,两次遍历影响微乎其微。

一个更“零开销”的实现(不判断空,直接拼接):

public static string JoinWithSurroundFast<T>(
    this IEnumerable<T> source,
    string separator,
    string prefix,
    string suffix,
    Func<T, string?>? selector = null)
{
    var strings = selector != null
        ? source.Select(selector)
        : source.Select(x => x?.ToString() ?? string.Empty);
    string middle = string.Join(separator, strings);
    return prefix + middle + suffix;
}
// 空集合时返回 prefix + suffix?不对,返回 prefix + "" + suffix = prefix + suffix,
// 恰好也是我们想要的行为!因为 string.Join 对空集合返回 string.Empty。

所以其实连 Any() 都可以省略,行为完全一致(空集合返回前缀+后缀)。我们可以直接采用上面的简化版,性能更好。

最终推荐实现(兼顾简洁和性能):

public static string JoinWithSurround<T>(
    this IEnumerable<T> source,
    string separator,
    string prefix,
    string suffix,
    Func<T, string?>? selector = null)
{
    if (source == null) throw new ArgumentNullException(nameof(source));
    separator ??= string.Empty;
    prefix ??= string.Empty;
    suffix ??= string.Empty;

    var strings = selector != null
        ? source.Select(selector)
        : source.Select(x => x?.ToString() ?? string.Empty);
    
    return prefix + string.Join(separator, strings) + suffix;
}

这样既干净又高效。


七、总结

  • string.Join 是 .NET 中连接字符串集合的利器,但它缺少前缀和后缀的支持。
  • 通过一个简单的扩展方法,我们为 IEnumerable<T> 赋予了类似 Java StringJoiner 的能力,代码简洁且性能优秀。
  • 你也可以选择实现一个完整的 StringJoiner 类,以支持流式添加元素。
  • 最终提供的扩展方法只有 10 行有效代码,却能在很多场景下提升代码的可读性和维护性。

下次当你需要拼接诸如 [1, 2, 3](a; b; c) 这样的字符串时,不要再手动加前缀后缀了,直接用这个扩展方法,让代码更优雅吧!


相关资源

开源协议:本文代码采用 MIT 协议,可随意使用。


希望这篇博客对你有帮助。如果你有更好的实现方式,欢迎在评论区交流!