前言
进制转换是编程里出现频率很高的一类小任务:调试时把内存字节打印成十六进制、把权限位打印成八进制、把算法中间状态打印成二进制、把短链接的 ID 编码成 36 进制。C++ 提供了好几套工具,从 C 风格的printf到流操纵符,再到 C++17 的std::to_chars,还有手写的逐位除法。工具多,坑也多。
常见的误解有三个。第一,以为std::hex只影响紧接着的那一次输出。它是流的状态标志,一旦设置就会一直生效,后面所有整数都按十六进制打印,直到你显式改回std::dec。第二,以为std::stoi能自动识别任意进制。它默认按十进制解析,遇到"FF"会抛std::invalid_argument;要按十六进制解析必须传第三个参数16,传0才是「按前缀自动判断」。第三,以为负数转成二进制就是「符号位加绝对值」。C++ 标准不规定有符号整数的内部表示(C++20 起才要求二进制补码),可移植代码不应依赖补码细节。
本文先回顾进位制的表示法,再依次介绍标准库的四种转换途径,最后给出一套手写的任意进制转换函数,并说明各自的适用边界。示例以 C++17 为基准。
一、进位制的基本表示
一个b进制数由若干位组成,第i位(从右往左、从 0 开始)的权值是b的i次方。十进制255等于二进制11111111、八进制377、十六进制FF。进制b的合法数字是0到b-1;超过 9 的数字用字母表示,10用A,11用B,依此类推。常用进制的范围是 2 到 36,因为 10 个数字加 26 个字母刚好 36 个符号。
C++ 源码里可以直接写几种进制字面量:十进制255、八进制0377(前导0)、十六进制0xFF。C++14 起还支持二进制字面量0b11111111和数字分隔符1'000'000。注意八进制的前导0是个历史遗留陷阱:int x = 010;的值是 8,不是 10。
| 进制 | 前缀/写法 | 255 的表示 | 常用场景 |
|---|
| 2 | 0b(C++14) | 0b11111111 | 位运算、标志位 |
| 8 | 前导0 | 0377 | Unix 文件权限 |
| 10 | 无 | 255 | 日常数值 |
| 16 | 0x | 0xFF | 内存、颜色、哈希 |
二、标准库的四种途径
1. 流操纵符:std::hex / std::oct / std::dec
<ios>提供的操纵符修改流的整型基数字段。它既能用于输出,也能用于输入。
// 适用于 C++17
#include <iomanip>
#include <iostream>
#include <sstream>
int main() {
std::cout << std::hex << std::uppercase << 255 << '\n'; // FF
std::cout << std::oct << 255 << '\n'; // 377
std::cout << std::dec << 255 << '\n'; // 255,必须显式改回
std::istringstream in("ff");
int value = 0;
in >> std::hex >> value; // value == 255
return 0;
}关键点是粘性:std::hex设置后一直有效,std::uppercase也是。如果函数内部设置了却没恢复,调用方后续的输出会莫名其妙变成十六进制。稳妥的写法是保存旧标志再恢复:
// 适用于 C++17
#include <ios>
#include <iostream>
#include <sstream>
std::string to_hex_stream(unsigned value) {
std::ostringstream os;
os.setf(std::ios::hex, std::ios::basefield); // 只改基数字段
os << value;
return os.str();
}这里用setf(mask, field)而不是os << std::hex,是因为前者只改动基数字段,不会顺带影响其他格式标志。对独立的ostringstream来说差别不大,但对std::cout这类共享流就很关键。
2. std::stoi / stol / stoll:字符串转整数
<string>里的这组函数接受(字符串, 起始位置指针, 进制)。进制为0时按 C 语言规则自动判断:0x开头按十六进制,0开头按八进制,否则按十进制。
// 适用于 C++17
#include <iostream>
#include <string>
int main() {
std::size_t pos = 0;
int a = std::stoi("1010", nullptr, 2); // 10
int b = std::stoi("0xFF", nullptr, 16); // 255,strtol 允许 0x 前缀
int c = std::stoi("0xFF", nullptr, 0); // 255,自动判断
int d = std::stoi("42abc", &pos, 10); // 42,pos == 2
std::cout << a << ' ' << b << ' ' << c << ' ' << d << ' ' << pos << '\n';
return 0;
}两个行为要记住:解析会在第一个非法字符处停止,不报错,只通过pos告诉你停在哪里;如果开头就没有合法数字,抛std::invalid_argument;如果数值超出返回类型的范围,抛std::out_of_range。因此解析用户输入时必须捕获异常,并检查pos是否覆盖了整个字符串,否则"12abc"会被当成合法的 12。
3. std::bitset:二进制展示
<bitset>的to_string()输出固定宽度的二进制串,宽度由模板参数决定,不足补前导零。它适合展示位标志,不适合任意进制。
// 适用于 C++17
#include <bitset>
#include <iostream>
#include <string>
int main() {
std::bitset<8> bits(5);
std::cout << bits.to_string() << '\n'; // 00000101
std::bitset<8> parsed(std::string("11110000"));
std::cout << parsed.to_ulong() << '\n'; // 240
return 0;
}std::bitset的构造函数接受unsigned long long或std::string。字符串里出现非0/1字符会抛std::invalid_argument,长度超过模板参数会抛std::out_of_range。位宽在编译期固定,所以它无法处理运行期才知道宽度的数。
4. std::to_chars / std::from_chars:C++17 的无分配转换
<charconv>提供了一对底层转换函数。整数版本支持 2 到 36 进制,不依赖 locale,不分配内存,通过返回的std::errc报告错误。
// 适用于 C++17
#include <array>
#include <charconv>
#include <iostream>
#include <string>
#include <system_error>
int main() {
std::array<char, 32> buf{};
auto [ptr, ec] = std::to_chars(buf.data(), buf.data() + buf.size(), 255, 16);
if (ec == std::errc{}) {
std::string s(buf.data(), ptr); // "ff",小写
std::cout << s << '\n';
}
int value = 0;
const char* text = "777";
auto [p2, ec2] = std::from_chars(text, text + 3, value, 8);
if (ec2 == std::errc{}) {
std::cout << value << '\n'; // 511
}
return 0;
}to_chars写入的是不以空字符结尾的字符序列,必须用返回的ptr确定长度。缓冲区不够时返回std::errc::value_too_large,此时不写入任何内容。from_chars同样不做越界检查之外的校验,解析失败返回std::errc::invalid_argument,溢出返回std::errc::result_out_of_range。整数版本的to_chars对大于 10 的进制输出小写字母,没有大写选项,需要大写就自己转换。
三、手写任意进制转换
标准库覆盖了常见需求,但有时需要完全控制输出(比如固定宽度、补零、大写、自定义字符表)。核心算法只有两步:转出用「除基取余、逆序排列」,转入用「乘基累加」。
// 适用于 C++17
#include <algorithm>
#include <limits>
#include <stdexcept>
#include <string>
std::string to_base(unsigned long long value, int base) {
if (base < 2 || base > 36) {
throw std::invalid_argument("base must be in [2, 36]");
}
static const char kDigits[] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
if (value == 0) {
return "0";
}
std::string out;
const auto ub = static_cast<unsigned long long>(base);
while (value != 0) {
out.push_back(kDigits[value % ub]);
value /= ub;
}
std::reverse(out.begin(), out.end());
return out;
}
unsigned long long from_base(const std::string& text, int base) {
if (base < 2 || base > 36) {
throw std::invalid_argument("base must be in [2, 36]");
}
if (text.empty()) {
throw std::invalid_argument("empty input");
}
const auto ub = static_cast<unsigned long long>(base);
const auto max = std::numeric_limits<unsigned long long>::max();
unsigned long long result = 0;
for (char c : text) {
int digit = -1;
if (c >= '0' && c <= '9') {
digit = c - '0';
} else if (c >= 'a' && c <= 'z') {
digit = c - 'a' + 10;
} else if (c >= 'A' && c <= 'Z') {
digit = c - 'A' + 10;
}
if (digit < 0 || digit >= base) {
throw std::invalid_argument("invalid digit for base");
}
const auto d = static_cast<unsigned long long>(digit);
if (result > (max - d) / ub) {
throw std::overflow_error("value does not fit in 64 bits");
}
result = result * ub + d;
}
return result;
}几个设计取舍值得说明。返回类型用unsigned long long,是为了让「溢出」有一个明确的边界;如果业务需要负数,可以额外维护符号位,把绝对值交给这两个函数。溢出检查写成result > (max - d) / ub,而不是先乘后判断——先乘就已经溢出了,而无符号溢出虽然回绕有定义,但结果已经错了。字符表放在函数内的static const char[],避免每次调用构造字符串。
对固定宽度的需求(比如把每个字节打印成两位十六进制),在转换结果前补零即可:
// 适用于 C++17
#include <iomanip>
#include <sstream>
#include <string>
std::string byte_to_hex(unsigned char byte) {
std::ostringstream os;
os << std::hex << std::setw(2) << std::setfill('0')
<< static_cast<unsigned>(byte);
return os.str();
}这里必须先转成unsigned。如果把unsigned char直接交给流,它会按字符类型输出(打印成字母或不可见字符),而不是数字。
常见坑点
- ❌ 用完
std::hex不恢复,导致后续所有输出都变成十六进制。
✅ 显式写std::dec恢复;或在独立的ostringstream里做格式化。
- ❌
std::stoi("FF")期望得到 255。
✅ 必须传进制:std::stoi("FF", nullptr, 16)。
- ❌ 解析用户输入时只捕获异常,不检查
pos,"12abc"被当成 12。
✅ 检查pos == text.size(),或自行拒绝含多余字符的输入。
- ❌
char c = 0xFF;之后直接拿它做数值运算。
✅char是否有符号是实现定义行为;涉及字节数值时用unsigned char或std::uint8_t。
- ❌ 手写转换时先乘后判溢出:
result = result * base + d;再比较。
✅ 先判result > (max - d) / base,再执行乘加。
- ❌ 用
to_chars后把缓冲区当 C 字符串,直接strlen或构造std::string(buf.data())。
✅ 结果不以空字符结尾,必须用返回的ptr计算长度。
- ❌ 用
std::stoi解析超过int范围的大十六进制数。
✅ 换std::stoll/std::stoull,或直接用std::from_chars并检查错误码。
- ❌ 认为负数转二进制就是「符号位加绝对值」,并依赖这个结果做位运算。
✅ 标准不规定有符号整数的内部表示;需要位模式时先转成对应的无符号类型。
总结
| 方式 | 头文件 | 支持进制 | 是否分配内存 | 适用场景 |
|---|
| 流操纵符 | <ios>/<iomanip> | 8/10/16 | 可能 | 快速输出、调试打印 |
std::stoi家族 | <string> | 2~36 | 可能 | 解析配置、命令行 |
std::bitset | <bitset> | 2 | 是 | 固定宽度位展示 |
std::to_chars | <charconv> | 2~36 | 否 | 高性能、无异常转换 |
| 手写除法 | 无 | 任意 | 是 | 自定义字符表、固定宽度 |
日常需求优先用标准库:输出调试信息用流操纵符,解析输入用stoi家族并检查异常与pos,追求无分配和高性能用to_chars/from_chars。只有在需要完全控制格式(补零、大写、自定义字符集、任意进制)时,才值得手写那两个函数。无论用哪种方式,都要记住三件事:进制范围要校验、溢出要检查、流的状态要恢复。