告别
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() 会额外枚举一次集合。但实际上:
-
对于
ICollection<T>(如List<T>、T[]),Any()只是检查Count属性,O(1) 开销。 -
对于纯粹的
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>赋予了类似 JavaStringJoiner的能力,代码简洁且性能优秀。 - 你也可以选择实现一个完整的
StringJoiner类,以支持流式添加元素。 - 最终提供的扩展方法只有 10 行有效代码,却能在很多场景下提升代码的可读性和维护性。
下次当你需要拼接诸如 [1, 2, 3] 或 (a; b; c) 这样的字符串时,不要再手动加前缀后缀了,直接用这个扩展方法,让代码更优雅吧!
相关资源
开源协议:本文代码采用 MIT 协议,可随意使用。
希望这篇博客对你有帮助。如果你有更好的实现方式,欢迎在评论区交流!