AI 技术博客
返回首页
Python 基础 16 分钟阅读

类型转换:显式、隐式与那些坑

Python类型转换浮点数异常边界

引言

这是第二章「变量、类型与运算符」的第 6 篇。前 5 篇我们学习了:变量绑定(名字只是对象的引用,第 1 篇)、数字类型的完整画像(int/float/complex,第 2 篇)、布尔值与真值测试(第 3 篇)、字符串字面量与转义、原始字符串(第 4 篇)、f-string 格式化(第 5 篇)。你已经能在不同类型之间”感知”到差异——整数可以精确相等,浮点数会显示成 0.30000000000000004,字符串有引号。本篇回答一个问题:数据如何从一种类型变成另一种类型?两种途径——显式调用构造函数,以及运算中解释器悄悄完成的隐式转换。这些机制每天都在发生,但只有理解了边界,你写的程序才不会在生产环境突然崩溃。阅读本篇前,你应熟练掌握前 5 篇的概念,尤其是「对象不可变、赋值是重新绑定」这一底层模型。

概念与原理:转换的本质是构造新对象

先建立最重要的直觉:Python 中的类型转换不是”修改”,而是”构造”。int 对象、float 对象、str 对象一旦创建就不可变。int(3.9) 并没有把那个 3.9 的浮点对象”截断成 3”,而是基于 3.9 的数值,在内存中构造了一个全新的 int 对象 3,并让名字绑定到它。这与第 1 篇讲的「b = a 之后修改 a 不影响 b」完全同源——转来转去,老的绑定纹丝不动。

按触发方式,转换分两类:

  • 显式转换(explicit conversion):你主动调用类型构造函数,如 int(x)、float(x)、str(x)、bool(x)、complex(x)。
  • 隐式转换(implicit conversion):二元运算时解释器自动把操作数统一成”更宽”的类型,如 1 + 2.0 中整数被提升为浮点。

隐式转换有个重要方向:int → float → complex。1 + 2j 之类混合运算的结果类型,永远取”金字塔”里更靠右的那一个。为什么是单向的?因为 float 能表示 int 的全部常用数值范围,complex 又能包容 float,统一成宽类型不会(在大多数情况下)丢失数值本身。但注意——“更宽”不等于”更精确”:float 只有约 15~17 位十进制有效精度,超大整数一旦掺进浮点运算就可能丢位(见下文易错点 4)。

操作与实现:三类构造函数的行为总览

数字构造函数:从数值对象转换

print(int(-3.7))          # -3,向零截断(不是四舍五入)
print(int(True))          # 1,布尔参与数字转换
print(float(2))           # 2.0
print(complex(3, 4))      # (3+4j):由实部虚部构造

int() 接收 float 时采用向零截断(truncation toward zero):正数向下取整、负数向上取整,int(2.9) 得 2,int(-2.9) 得 -2。这与 round() 的四舍五入不是一回事,极易混淆,后面易错点 3 专门展开。

字符串转换:int() 的进制参数

int() 最重要的重载是接收字符串。当目标是”解析用户输入或配置文件文本”时,字符串是唯一输入形式:

print(int("12"))          # 12,十进制
print(int(" 12 "))        # 12,首尾空白被忽略
print(int("-1_000"))      # -1000,支持下划线分隔符
print(int("0xFF", 0))     # 255,base=0 表示按前缀自行推断

关键的第二个参数 base(进制基数,2~36)让字符串解析变得极其灵活。它在文本处理里是常客:读取二进制协议字段、解析十六进制颜色、处理网络数据包时,你几乎天天与它打交道:

print(int("10", 2))       # 2,把 "10" 按二进制理解
print(int("10", 16))      # 16,把 "10" 按十六进制理解
print(int("0b101", 0))    # 5,base=0 时 "0b" 前缀自动识别

注意 int("10", 2) 与 int("10", 16) 的输入字符串完全相同,结果却不同——进制只影响解释方式,与字符串本身无关。这是初学者最常见的困惑点之一。

字符串转换:float() 与 complex()

float() 接受的字符串比 int() 宽松得多,因为它要覆盖科学计数法与特殊值:

print(float("1e3"))       # 1000.0,科学计数法
print(float("3.0"))       # 3.0,带小数点的写法
print(float("inf"))       # inf,正无穷
print(float("nan"))       # nan,非数值
print(complex("1+2j"))    # (1+2j),复数只有一种解析格式

字符串转数字的 ValueError 边界

理解”哪些字符串合法”比背诵错误消息更重要。int() 的合法输入是:可选正负号 + 空白 + 十进制整数文本(可带下划线)。它不接受小数点、指数形式,即使数值上”恰好是整数”也不行——这是设计取舍:int() 认的是整数语法,而不是”能四舍五入成整数的任何写法”:

def safe_int(s: str):
    """把字符串转 int,失败时返回带原因的描述。"""
    try:
        return int(s)
    except ValueError as e:
        return f"ValueError: {e}"

print(safe_int("1.5"))     # ValueError: invalid literal for int() with base 10: '1.5'
print(safe_int("3.0"))     # ValueError: invalid literal for int() with base 10: '3.0'
print(safe_int("1e3"))     # ValueError: invalid literal for int() with base 10: '1e3'
print(safe_int(""))        # ValueError: invalid literal for int() with base 10: ''
print(safe_int("1"))       # 1,正常路径

三个失败例子的共同点是:文本包含小数点或 e,不属于十进制整数的字面量语法,于是抛出 ValueError。对应的正确姿势是分两步——若用户输入的是 "3.14",先 float(s) 再 int();若输入 "1e3",同样先过 float()。把”解析失败”与”数值越界”区分开还有个细节:int("2" * 500) 不会报错(Python 整数任意精度),但 float("1e400") 会返回 inf 而不是报错,设计上 float 就是”溢出→无穷”。

还有两个 float 特有的边界需要记住,它们的报错类型都不一样:

try:
    int(float("nan"))
except ValueError as e:
    print("ValueError:", e)      # cannot convert float NaN to integer
try:
    int(float("inf"))
except OverflowError as e:
    print("OverflowError:", e)   # cannot convert float infinity to integer

NaN(not a number)与无穷大无法落进整数,int() 分别走 ValueError 与 OverflowError 两条通道。实战中它们经常从解析失控数据(如除零、空值运算)传来,捕获时按类型分别处理,日志里才能看出问题本质。

数字转字符串:反向旅程

反向转换同样重要。str() 对 int 是精确无损的;对 float 则输出最短表示——保证 float(str(x)) == x 往返成立,所以 str(0.1) 是 "0.1" 而不是那串 0.30000000000000004 式的长尾巴。bool 转字符串更特别:str(True) 是 "True",不是 "1";而 int(True) 才是 1。文本展示与数值语义,二者井水不犯河水:

print(str(0.1), str(True), str(3.14))   # 0.1 True 3.14
print(str(1e-7), str(1e-8))             # 1e-07 1e-08,小浮点用科学计数法
print(float(str(0.1)) == 0.1)           # True,往返一致

把数字拼进字符串是工程高频动作,但 + 只接受字符串,硬拼直接抛 TypeError:"总分: " + 3 报 can only concatenate str (not "int") to str。正确做法是 f-string(第 5 篇)或显式 str(3)。这也解释了为什么 "3" + "4" 是 "34" 而 3 + 4 是 7——运算符不做隐式类型转换,这是 Python 与不少脚本语言(如 JavaScript 的 "3" + 4 得 "34")刻意不同的行为:宁可报错,也不静默猜你的意图。

一个完整的工程片段:解析成绩文件

把本篇的知识串成一个真实场景:读取 input() 得到的成绩行并计算总分。input() 返回的永远是字符串,因此”字符串 → 数字 → 运算 → 格式化输出”是必经之路:

line = input("请输入三科成绩,以逗号分隔(如 90,75,60):")
parts = line.split(",")              # 字符串切分
try:
    scores = [float(p.strip()) for p in parts]   # 逐个转数字
except ValueError:
    print("输入格式错误:存在无法解析的数字")
else:
    total = sum(scores)              # 数字才可求和
    print(f"总分 {total:.1f},平均 {total / len(scores):.1f}")

这段代码自动用上了本章前半程的全部工具:f-string 格式化(第 5 篇)、真值测试与异常边界(本篇)、以及 sum() 对数字的加宽行为。注意 float(p.strip()) 中先 strip() 再转换,正是为了吃掉 " 90" 这类带空白的分片——空白合法,但只对字符串两侧;而用户习惯性打出的全角逗号 "90,75" 不会被半角 split(",") 切开,一整片 "90,75" 就会在 float() 处炸出 ValueError,被 except 拦下给出友好提示。这类”用户永远不按你设想的格式输入”的教训,是从脚本走向工具的第一课。

隐式转换:数字金字塔与代价

隐式转换在两个场景出现:数值混合运算与布尔混入算术。演示如下:

print(type(1 + 2.0))          # <class 'float'>,int 被提升为 float
print(type(1 + 2j))           # <class 'complex'>,一切数值最终并入 complex
print(True + True)            # 2,bool 是 int 的子类,直接当 1 用
print(sum([True, False, True]))  # 2,用布尔列表做计数

第 3 篇讲过布尔是 int 的子类、True == 1 成立——这就解释了一切:布尔参与算术时被自动当作 0/1。这个特性在统计场景很实用,但也会埋下隐患(易错点 2)。

隐式转换的代价是精度丢失。float 只有约 15~17 位十进制有效数字,整数的位宽却任意长。两者一相遇,掉队的是大整数:

big = 1_000_000_000_000_000_001   # 10^18 + 1
print(big + 0.0)                  # 1e+18,末尾的 1 在转换瞬间就被丢弃

big + 0.0 先把 10^18+1 转成 float,而 float 在 10^18 量级的间隔大于 1,于是 1 没了。这是”更大类型”神话的第一块裂缝。

与之并列的还有二进制浮点的固有误差——0.1 + 0.2 在二进制下无法精确表示(第 2 篇提过)。工程上的三个对策依次是:比较用 math.isclose() 容差、金额等十进制场景用 decimal.Decimal、展示用格式化而不是拼接:

print(0.1 + 0.2)                       # 0.30000000000000004
import math
print(math.isclose(0.1 + 0.2, 0.3))    # True,容差比较
from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2"))  # 0.3,十进制精确运算

Decimal("0.1") 与 Decimal(0.1) 也不同——前者按字符串的十进制理解,后者会先经过那个不精确的二进制 float,务必用字符串构造。

易错点与陷阱

先给出一条贯穿全局的检查清单:遇到转换就依次问三个问题——转换方向对不对(数字转字符串用 str(),字符串转数字按语法选 int() 或 float())、会不会静默丢精度(涉及大整数、金额时禁止 float 掺和)、失败时程序怎么死(ValueError/OverflowError/TypeError 分别出现在哪条路径)。带着清单看下面的案例:

1. int() 直接解析小数形式字符串必炸。 input() 拿到的内容永远是字符串。用户输入 "3.14" 时 int(s) 抛出 ValueError,程序当场终止。正确写法是先 float() 再 int(),或者用 try/except 兜住并给用户友好提示。检验标准很简单:想从文本解析什么”写法”,就用接受该写法的构造函数——小数和指数归 float(),整数写法归 int()。

2. 布尔混进算术的反直觉:bool("False") 是 True。 bool() 只做真值测试(第 3 篇),非空字符串恒为真,所以 bool("False")、bool("0") 都是 True。并且由于 True == 1,True + True == 2 成立。后果一:拿 sum() 数布尔会产生”真假混合”的翻译;后果二:把用户字符串转布尔想当然地写 bool(user_input),得到的几乎永远是 True。解析 "true"/"false" 文本应显式比较:user_input.strip().lower() == "true"。

3. 截断与舍入不是一回事。 int(2.9) 是 2,int(-2.9) 是 -2——向零截断;round(2.5) 却是 2、round(3.5) 是 4——Python 的 round 采用银行家舍入(banker’s rounding),即 .5 时舍入到最近的偶数,round(-2.5) 得 -2。想对正数做四舍五入,标准写法是 int(x + 0.5);对任意符号则是 math.floor(x + 0.5)。

4. 隐式转换的静默精度丢失。 大整数与浮点混算、(1 << 53) + 1 转 float 等场景,数值悄悄失真且毫无警告。涉及金额、ID、校验和这类”必须精确”的数据,拒绝让 float 掺和;这是选择 Decimal 和只做整数运算的第一理由。

小结

类型转换分显式与隐式两种:显式是调用 int/float/str/bool/complex 构造函数,本质是”构造新对象”,绝不修改原对象;隐式发生在数值运算中,方向为 int→float→complex,且布尔可当 0/1 参与算术。字符串转数字的边界是 int() 只认整数语法(含 base 参数与下划线),小数点与指数必须交 float() 处理。最大代价是精度:float 的有限精度既制造了 0.1+0.2 的误差,也吞掉超大整数的低位。下一篇文章把算术、比较、逻辑运算符以及它们的优先级系统地串起来——转换是运算符的”燃料”,优先级是”点火顺序”。

练习与思考题

  1. 写出下列表达式的输出并运行验证:int("101", 2)、int("101", 8)、int("101", 16)、float("1_000.5")。为什么输入相同、结果不同?
  2. 写一个 safe_int(s),返回 None 而非抛异常(提示:除 ValueError 外,还需考虑参数根本不是字符串的情形)。
  3. round(0.5)、round(1.5)、round(2.5) 的输出是什么?为什么与初等数学的”四舍五入”不同?请用银行家舍入的规则解释。
  4. 思考题:1e308 * 10 的结果是什么类型、什么值?int(1e308 * 10) 又会怎样?运行验证,并说明其与 float 溢出设计的联系。