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, char* fmt, ...);
不过这很C,不是很C++(而且这样的实现有不少问题,文末再述),所以我们需要这样的接口:
template<typename... T>
int sprintf_x(char* buf, size_t len, char* fmt, T... args);
3. 实现
3.1. 主体
显然,我们需要解析格式指示参数中的%1、%2…,根据解析出来的编号定位后面的参数,再进行格式化:
template<typename... T>
int sprintf_x(char* buf, size_t len, char* fmt, T... args)
{
static_assert(sizeof...(args) <= 10, "too much args!");
if (nullptr == buf || len == 0 || nullptr == fmt) return -1;
return sprintf_impl_<CatStr_, sizeof...(args)>(buf, len, fmt, args...);
}
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 seq, T... args) -> int
{
auto rlen = CatFmtStrByNo_<C, N>(buf, len, seq, 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), 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), 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 CatFmtStrByNo_(char* buf, size_t len, size_t seq, T... args)
{
switch (seq)
{
case 1: return C<1 <= N, 1, T...>()(buf, len, args...); break;
case 2: return C<2 <= N, 2, T...>()(buf, len, args...); break;
case 3: return C<3 <= N, 3, T...>()(buf, len, args...); break;
case 4: return C<4 <= N, 4, T...>()(buf, len, args...); break;
case 5: return C<5 <= N, 5, T...>()(buf, len, args...); break;
case 6: return C<6 <= N, 6, T...>()(buf, len, args...); break;
case 7: return C<7 <= N, 7, T...>()(buf, len, args...); break;
case 8: return C<8 <= N, 8, T...>()(buf, len, args...); break;
case 9: return C<9 <= N, 9, T...>()(buf, len, args...); break;
case 10: return C<10 <= N, 10, T...>()(buf, len, args...); break;
default: return 0; break;
}
}
这个函数看起来怪怪的,它根本就是个传声筒,它通过其模板形参实例化一个临时对象(是个可调用的对象),然后将参数原封不动地转发给这个可调用对象。那么这个模板函数有啥存在的意义呢?
它的意义就在于它将一个运行期才能确定的参数(即编号)用硬编码的方式在编译期实现分发(就是那些个1、2、… 10的case)。这样做“可能”会提高一点点性能(之所以说可能,是因为我没测试过)。它的意义同时也是它的缺陷,即它只能支持最多10个参数的格式化,不过如果你想多支持一些参数,改起来也简单。
接着来看这个模板的模板参数,它应该是一个可调用“对象”(实际上是类):
template<bool OK, size_t N, typename... T>
struct CatStr_
{
inline int operator()(char* buf, size_t len, T... args)
{
using type = typename ArgsSelector<N, T...>::type;
return FmtWrapper_<IsStr_<type>::vaule, type>()(buf, len, ArgsSelector<N, T...>::value(args...));
}
};
template<size_t N, typename... T>
struct CatStr_<false, N, T...>
{
inline int operator()(char*, size_t, T...)
{
return 0;
}
};
这是一个仿函数模板。第一个bool模板形参主要是为了解决编译问题,比如:
sprintf_x(buf, _countof(buf), "学%1只需%4天,从%2到%3", "C++", "入门", "放弃", 21);
这样的代码编译就通不过,会提示ArgsSelector<*>未定义。
3.2.1. 可变参数筛选器
前面的仿函数通过ArgsSelector模板来实现按编号筛选参数:
template<size_t N, typename... T> struct ArgsSelector;
template<typename T1, typename... T>
struct ArgsSelector<1, T1, T...>
{
using type = T1;
static inline type value(T1 p, T...) { return p; }
};
template<size_t N, typename T1, typename... T>
struct ArgsSelector<N, T1, T...> : public ArgsSelector<N - 1, T...>
{
static inline typename ArgsSelector<N - 1, T...>::type value(T1 p, T... args)
{
return ArgsSelector<N - 1, T...>::value(args...);
}
};
这个类模板通过递归实现从一个可变参数序列中筛选出指定编号位置上的变量。
3.2.2. 格式化
筛选出目标变量之后,就可以执行格式化了:
template<bool Depth, typename S>
struct FmtWrapper_
{
inline int operator()(char* buf, size_t len, S src)
{
return _snprintf_s(buf, len, _TRUNCATE, "%s", src);
}
};
template<typename S>
struct FmtWrapper_<false, S>
{
inline int operator()(char* buf, size_t len, S src)
{
return to_str_(buf, len, src);
}
};
如果是字符串类型,通过_snprintf_s格式化,否则通过to_str_x格式化,这种分发是通过IsStr_模板实现的:
template<typename T> struct IsStr_ { static const bool vaule{ false }; };
template<> struct IsStr_<char*> { static const bool vaule{ true }; };
template<> struct IsStr_<const char*> { static const bool vaule{ true }; };
下面简单列出一系列to_str_x函数:
inline int to_str_x(char* buf, size_t len, unsigned 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 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);
因为我们的格式化指示参数中只有编号,精细的格式化信息都给丢弃了。很显然,用户指定的精细格式化参数,还是得保留。那么,我们做一下变通,要求客户代码把精细化格式跟在相应的参数后面:
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, char* fmt, T... args)
{
static_assert(sizeof...(args) <= 20, "too much args!");
if (nullptr == buf || len == 0 || nullptr == fmt) return -1;
return sprintf_impl_<FmtStr_, sizeof...(args) / 2>(buf, len, fmt, args...);
}
可变参数的数量翻了一倍,所以有:
sprintf_impl_<FmtStr_, sizeof...(args) / 2>(buf, len, fmt, args...);
可调用“对象”FmtStr_的实现:
template<bool OK, size_t N, typename... T>
struct FmtStr_
{
inline int operator()(char* buf, size_t len, T... args)
{
return _snprintf_s(buf, len, _TRUNCATE, ArgsSelector<N * 2, T...>::value(args...),
ArgsSelector<N * 2 - 1, T...>::value(args...));
}
};
template<size_t N, typename... T>
struct FmtStr_<false, N, 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调用约定下,无法确定各个参数的类型);放在前面的话,因为它们都是字符串类型,压栈时,可以都是指针,长度固定,且紧挨着第一个具名参数(就是格式指示串)。先解析出fmt字符串中的编号,再根据编号,找到对应的精细化格式参数(都是字符串),解析之,得到相应参数的具体类型(也就是%d、%s之类的),最后再找到(结合stdarg.h中的那几个宏)对应的参数进行格式化。
具体的实现就不赘述了。这里只简单总结一下:
- C风格方案可移植性较差,它需要在cdecl调用约定下才能正常工作
- C风格无法对参数类型进行校验,因为可变参数的类型全赖客户在格式化指示串中指定;而C++风格是可以校验的,如果你想那样做的话
- 吹毛求疵的话,如果在调用函数时,某些参数没有压到栈上,而是放在寄存器中,C风格方案就无法正常工作了
- C++风格的方案,看起来过于复杂了,各位朋友有更简单的方案还请指点一二
在动态语言中(比如Lua)实现类似的需求,简单得多,可参见《Lua变长参数按编号格式化拼接》一文。
转载:https://blog.csdn.net/whyel/article/details/102519705