1. 前言
C/C++中用sprintf格式化拼接字符串是惯用手法,比如:
char buf[1024];
sprintf_s(buf, _countof(buf), "学%s,从%s到%s,只需%d天", "C++", "入门", "放弃", 21);
大家都用烂了。相信也有人手误,写过这样的代码:
char buf[1024];
sprintf_s(buf, _countof(buf), "学%s,从%s到%s,只需%s天", "C++", "入门", "放弃", 21);
哦噢,最后的%d写成了%s!程序可能就死给你看了。
假设某一天,你想调整一下:
char buf[1024];
sprintf_s(buf, _countof(buf), "学%s只需%d天,从%s到%s", "C++", 21, "入门", "放弃");
或许是疏忽了,你忘记调整后面的参数顺序:
char buf[1024];
sprintf_s(buf, _countof(buf), "学%s只需%d天,从%s到%s", "C++", "入门", "放弃", 21);
那么你的程序可能会再一次遭遇崩溃。于是就出现了一个需求,期望字符串拼接时按编号(而不是按顺序)来定位参数:
char buf[1024];
sprintf_s(buf, _countof(buf), "学%1,从%2到%3,只需%4天", "C++", "入门", "放弃", 21);
调整后(后面的参数顺序不需要调整):
char buf[1024];
sprintf_s(buf, _countof(buf), "学%1只需%4天,从%2到%3", "C++", "入门", "放弃", 21);
下面,我们就开始做这样一个玩具。
2. 接口
接口看起来像是这样的:
int sprintf_x(char* buf, size_t len, const char* fmt, ...);
不过这很C,不是很C++(而且这样的实现有不少问题,文末再述),所以我们需要接口看起来像是这样的:
template<typename... T>
int sprintf_x(char* buf, size_t len, const char* fmt, T&&... args);
// 数组
template<size_t N, typename... T>
int sprintf_x(char (&buf)[N], const char* fmt, T&&... args);
3. 实现
3.1. 主体
首先,我们做一层简单的封装,对参数做个简单的校验,比如变长参数的个数要求不超过10个(可以根据实际需要调整):
template<typename... T>
int sprintf_x(char* buf, size_t len, const char* fmt, T&&... args)
{
static_assert(sizeof...(args) <= 10, "too more args!");
if (nullptr == buf || len == 0 || nullptr == fmt) return -1;
return sprintf_impl_<CatStr_, sizeof...(args)>(buf, len, fmt, std::forward<decltype(args)>(args)...);
}
// 数组
template<size_t N, typename... T>
int sprintf_x(char (&buf)[N], const char* fmt, T&&... args)
{
static_assert(sizeof...(args) <= 10, "too more args!");
if (nullptr == buf || nullptr == fmt) return -1;
return sprintf_impl_<CatStr_, sizeof...(args)>(buf, N, fmt, std::forward<decltype(args)>(args)...);
}
具体的工作由sprintf_impl_来实现。首先,我们需要解析出fmt中的%1、%2…,根据解析出来的编号定位后面的参数,然后再进行格式化。sprintf_impl_模板的第1个模板参数也是一个模板,表示一个可调用的“类”,主要用于筛选指定参数并进行格式化(可以根据具体需求定制,后有详述),第2个模板参数是整型值N,表示需要格式化的参数个数:
template<template<bool, size_t, typename...> typename C, size_t N, typename... T>
int sprintf_impl_(char* buf, size_t len, const char* fmt, T&&... args)
{
auto catFmtStr_ = [](char* buf, size_t len, size_t pos, T&&... args) -> int
{
auto rlen = CatStrByPos_<C, N>(buf, len, pos, std::forward<decltype(args)>(args)...);
if (rlen < 0) rlen = len - 1; // 截断了,实际拷贝的字符不算结束符
return rlen;
};
--len; // 为结束符预留一个位置
size_t xlen = len;
const char* p = 0;
for (; *fmt && len > 0; ++fmt)
{
if (p)
{
if (*fmt >= '0' && *fmt <= '9')
continue;
else
{ // 解析出了编号,根据编号定位参数,进行格式化
auto rlen = catFmtStr_(buf, len, atoi(p), std::forward<decltype(args)>(args)...);
buf += rlen;
len -= rlen;
p = 0;
}
}
if (*fmt == '%')
{
if (*(fmt + 1) == '%')
++fmt;
else
{
p = fmt + 1;
continue;
}
}
*buf++ = *fmt;
--len;
}
if (p && *p && len > 0) // 最后的%,如果有的话
{
auto rlen = catFmtStr_(buf, len, atoi(p), std::forward<decltype(args)>(args)...);
buf += rlen;
len -= rlen;
}
*buf = '\0';
return (xlen - len); // 实际拷贝的字符数(不含结束符)
}
函数的实现主体就是这些代码了。
3.2. 按编号定位格式化
现在我们已提取到编号,接下来看看怎么按编号定位到对应的参数,并将其格式化进字符串缓冲区。
template<template<bool, size_t, typename...> typename C, size_t N, typename... T>
inline int CatStrByPos_(char* buf, size_t len, size_t pos, T&&... args)
{
switch (pos)
{
case 1: return C<1 <= N, 1, T...>()(buf, len, std::forward<decltype(args)>(args)...); break;
case 2: return C<2 <= N, 2, T...>()(buf, len, std::forward<decltype(args)>(args)...); break;
case 3: return C<3 <= N, 3, T...>()(buf, len, std::forward<decltype(args)>(args)...); break;
case 4: return C<4 <= N, 4, T...>()(buf, len, std::forward<decltype(args)>(args)...); break;
case 5: return C<5 <= N, 5, T...>()(buf, len, std::forward<decltype(args)>(args)...); break;
case 6: return C<6 <= N, 6, T...>()(buf, len, std::forward<decltype(args)>(args)...); break;
case 7: return C<7 <= N, 7, T...>()(buf, len, std::forward<decltype(args)>(args)...); break;
case 8: return C<8 <= N, 8, T...>()(buf, len, std::forward<decltype(args)>(args)...); break;
case 9: return C<9 <= N, 9, T...>()(buf, len, std::forward<decltype(args)>(args)...); break;
case 10: return C<10 <= N, 10, T...>()(buf, len, std::forward<decltype(args)>(args)...); break;
default: return 0; break;
}
}
这个函数看起来怪怪的,它根本就是个传声筒,它通过其第1个模板参数实例化一个临时对象(是个可调用的对象),然后将参数原封不动地转发给这个可调用对象。那么这个模板函数有啥存在的意义呢?
它的意义就在于它将一个运行期才能确定的参数(即编号)用硬编码的方式在编译期实现分发(就是那些个1、2、… 10的case)。这样做“可能”会提高一点点性能(之所以说可能,是因为我没测试过)。它的意义同时也是它的缺陷,即它只能支持最多10个参数的格式化,不过如果你想多支持一些参数,改起来也简单。
接着来看这个模板的模板参数,它应该是一个可调用“类”:
template<bool OK, size_t P, typename... T>
struct CatStr_
{
inline int operator()(char* buf, size_t len, T&&... args)
{
using type = typename ArgsSelector<P, T...>::type;
return FmtWrapper_<StrType_<type, 0>::vaule, type>()(buf, len,
ArgsSelector<P, T...>::value(std::forward<decltype(args)>(args)...));
}
};
template<size_t P, typename... T>
struct CatStr_<false, P, T...>
{
inline int operator()(char*, size_t, T&&...)
{
return 0;
}
};
这是一个仿函数模板。第1个bool模板形参主要是为了解决编译问题,比如:
sprintf_x(buf, _countof(buf), "学%1只需%4天,从%2到%3", "C++", "入门", "放弃", 21);
这样的代码编译就通不过,会提示ArgsSelector<*>未定义。
第2个模板参数是整型值P,表示想要在可变参数序列中选取的目标参数的位置(序号),通过可变参数筛选器ArgsSelector来筛选出位置P上的参数。
3.2.1. 可变参数筛选器
ArgsSelector模板来实现按编号筛选参数:
template<size_t P, typename... T> struct ArgsSelector;
template<typename T1, typename... T>
struct ArgsSelector<1, T1, T...>
{
using type = T1;
static inline type&& value(T1&& arg, T&&...) { return std::forward<T1>(arg); }
};
template<size_t P, typename T1, typename... T>
struct ArgsSelector<P, T1, T...> : public ArgsSelector<P - 1, T...>
{
static inline typename ArgsSelector<P - 1, T...>::type&& value(T1&& arg, T&&... args)
{
return ArgsSelector<P - 1, T...>::value(std::forward<decltype(args)>(args)...);
}
};
这个类模板通过递归模板实现从一个可变参数序列中筛选出指定位置上的变量。
3.2.2. 格式化
筛选出目标变量之后,就可以执行格式化了:
template<size_t X, typename S> struct FmtWrapper_;
template<typename S> struct FmtWrapper_<0, S>
{
inline int operator()(char* buf, size_t len, S src)
{
return to_str_x(buf, len, src);
}
};
template<typename S> struct FmtWrapper_<1, S>
{
inline int operator()(char* buf, size_t len, S src)
{
return _snprintf_s(buf, len, _TRUNCATE, "%s", src);
}
};
// 针对string类对象的格式化
template<typename S> struct FmtWrapper_<2, S>
{
inline int operator()(char* buf, size_t len, S&& src)
{
return _snprintf_s(buf, len, _TRUNCATE, "%s", src.c_str());
}
};
如果是字符串类型,通过_snprintf_s格式化(如果是string对象,则用其c_str()转换一下),否则通过to_str_x格式化,这种分发是通过StrType_模板实现的:
template<typename T, size_t N> struct StrType_ { enum { vaule = 0 }; };
template<> struct StrType_<char*, 0> { enum { vaule = 1 }; };
template<> struct StrType_<const char*, 0> { enum { vaule = 1 }; };
template<> struct StrType_<char*&, 0> { enum { vaule = 1 }; };
template<> struct StrType_<const char*&, 0> { enum { vaule = 1 }; };
template<> struct StrType_<std::string, 0> { enum { vaule = 2 }; };
template<> struct StrType_<std::string&, 0> { enum { vaule = 2 }; };
template<> struct StrType_<const std::string&, 0> { enum { vaule = 2 }; };
template<> struct StrType_<std::string&&, 0> { enum { vaule = 2 }; };
template<> struct StrType_<const std::string&&, 0> { enum { vaule = 2 }; };
template<typename T, size_t N> struct StrType_<T(&)[N], 0> { enum { vaule = 1 }; }; // 针对字符数组偏特化
下面简单列出一系列to_str_x函数:
inline int to_str_x(char* buf, size_t len, char n)
{
if (len > 0)
{
*buf++ = n; --len;
return 1;
}
return 0;
}
inline int to_str_x(char* buf, size_t len, short n)
{
return _snprintf_s(buf, len, _TRUNCATE, "%d", n);
}
inline int to_str_x(char* buf, size_t len, int n)
{
return _snprintf_s(buf, len, _TRUNCATE, "%d", n);
}
inline int to_str_x(char* buf, size_t len, long n)
{
return _snprintf_s(buf, len, _TRUNCATE, "%ld", n);
}
inline int to_str_x(char* buf, size_t len, __int64 n)
{
return _snprintf_s(buf, len, _TRUNCATE, "%I64d", n);
}
inline int to_str_x(char* buf, size_t len, unsigned char n)
{
return _snprintf_s(buf, len, _TRUNCATE, "%u", unsigned short(n));
}
inline int to_str_x(char* buf, size_t len, unsigned short n)
{
return _snprintf_s(buf, len, _TRUNCATE, "%u", n);
}
inline int to_str_x(char* buf, size_t len, unsigned int n)
{
return _snprintf_s(buf, len, _TRUNCATE, "%u", n);
}
inline int to_str_x(char* buf, size_t len, unsigned long n)
{
return _snprintf_s(buf, len, _TRUNCATE, "%lu", n);
}
inline int to_str_x(char* buf, size_t len, unsigned __int64 n)
{
return _snprintf_s(buf, len, _TRUNCATE, "%I64u", n);
}
inline int to_str_x(char* buf, size_t len, float n)
{
return _snprintf_s(buf, len, _TRUNCATE, "%f", n);
}
inline int to_str_x(char* buf, size_t len, double n)
{
return _snprintf_s(buf, len, _TRUNCATE, "%f", n);
}
inline int to_str_x(char* buf, size_t len, long double n)
{
return _snprintf_s(buf, len, _TRUNCATE, "%lf", n);
}
4. 精细格式化
到这里,我们基本上实现了需求。但是通常我们会有更精细的格式化需求:
char buf[1024];
sprintf_s(buf, _countof(buf), "学%.3s,从%s到%s,只需%-4d天", "C++", "入门", "放弃", 21);
那么,通过sprintf_x就很难实现了:
char buf[1024];
sprintf_x(buf, _countof(buf), "学%1,从%2到%3,只需%4天", "C++", "入门", "放弃", 21);
因为我们的fmt参数中只有编号,精细的格式化信息都给丢弃了。显然,用户指定的精细格式化参数,还是得保留。那么,我们做一下变通,要求客户代码把精细化格式跟在相应的参数后面:
char buf[1024];
sprintf_y(buf, _countof(buf), "学%1,从%2到%3,只需%4天", "C++", "%.3s", "入门", "%s", "放弃", "%s", 21, "%-4d");
sprintf_y的实现如下:
template<typename... T>
int sprintf_y(char* buf, size_t len, const char* fmt, T&&... args)
{
static_assert(sizeof...(args) <= 20, "too more args!");
if (nullptr == buf || len == 0 || nullptr == fmt) return -1;
return sprintf_impl_<FmtStr_, sizeof...(args) / 2>(buf, len, fmt, std::forward<decltype(args)>(args)...);
}
可变参数的数量翻了一倍,所以有:
sprintf_impl_<FmtStr_, sizeof...(args) / 2>(......);
可调用“类”FmtStr_的实现:
template<bool OK, size_t P, typename... T>
struct FmtStr_
{
inline int operator()(char* buf, size_t len, T&&... args)
{
return _snprintf_s(buf, len, _TRUNCATE,
ArgsSelector<P * 2, T...>::value(std::forward<decltype(args)>(args)...),
ArgsSelector<P * 2 - 1, T...>::value(std::forward<decltype(args)>(args)...));
}
};
template<size_t P, typename... T>
struct FmtStr_<false, P, T...> // 也是为了解决编译问题
{
inline int operator()(char*, size_t, T&&...)
{
return 0;
}
};
5. 补充
关于这个需求的C风格实现方案:
int sprintf_x(char* buf, size_t len, char* fmt, ...);
我们先看看用法:
char buf[1024];
sprintf_x(buf, _countof(buf), "学%1,从%2到%3,只需%4天", "C++", "入门", "放弃", 21);
客户没有显式指定每个参数的格式化指示符,我们确定不了可变参数序列中各个参数的类型,stdarg.h中的那几个宏也就没有用武之地。所以这种用法是没法支持的。退而求其次吧,我们要求客户这么用(和前面C++风格类似,显式提供格式):
char buf[1024];
sprintf_x(buf, _countof(buf), "学%1,从%2到%3,只需%4天", "%.3s", "%s", "%s", "%-4d", "C++", "入门", "放弃", 21);
你看到了,我们把格式指示符写在所有可变参数的前面,不能像C++风格那样跟在参数后面,因为如果跟在参数后面,我们会面临同样的问题(在cdecl调用约定下,无法确定各个参数的类型);放在前面的话,因为它们都是字符串类型,压栈时,可以都是指针,长度固定,且紧挨着第1个具名参数(就是fmt)。先解析出fmt字符串中的编号,再根据编号(结合stdarg.h中的那几个宏),找到对应的精细化格式参数(都是字符串),解析之,得到相应参数的具体类型(也就是%d、%s之类的),最后再找到对应的参数进行格式化。
具体的实现就不赘述了。这里只简单总结一下:
- C风格方案可移植性较差,它需要在cdecl调用约定下才能正常工作
- C风格无法对参数类型进行校验,因为可变参数的类型全赖客户在格式化指示串中指定;而C++风格是可以校验的,如果你想那样做的话
- 吹毛求疵的话,如果在调用函数时,某些参数没有压到栈上,而是放在寄存器中,C风格方案就无法正常工作了
- C++风格的方案,看起来过于复杂了,各位朋友有更简单的方案还请指点一二
在动态语言中(比如Lua)实现类似的需求,简单得多,可参见《Lua按编号拼接字符串》一文。