NOTE · Engineering Systems

Python 工程语义:引用、导入、生成器与边界处理

整理 Python 中容易在工程代码里引发隐蔽错误的语义,并补充卷积尺寸、媒体批处理和网络通信的边界。

这篇最大程度保留旧 Python 笔记中的语义、脚本与网络示例。可能移动原文件或缺少协议边界的代码已改写为可回退、可验证的版本。

模块、包与导入

推荐把项目作为包运行,而不是依赖当前工作目录碰巧可导入。

project/
├── pyproject.toml
├── src/
│   └── package_name/
│       ├── __init__.py
│       ├── core.py
│       └── tools/
│           ├── __init__.py
│           └── inspect.py
└── tests/

包内绝对导入:

from package_name.core import build_model

包内相对导入:

from ..core import build_model

从项目根目录运行模块:

python -m package_name.tools.inspect

若直接运行包内某个 .py 文件导致相对导入失败,优先修正启动方式和包结构,不要在代码里临时追加个人绝对路径到 sys.path

列表保存的是对象引用

records = []
state = {"score": 1}

records.append(state)
state["score"] = 2
records.append(state)

print(records)
# [{'score': 2}, {'score': 2}]

两项指向同一个字典。如果需要每个时间步的快照:

from copy import deepcopy

records.append(deepcopy(state))

对于只包含不可变值的一层容器,state.copy() 可能足够;嵌套对象仍会共享引用。判断标准不是“看起来像复制”,而是后续是否会修改内部对象。

生成器是延迟计算,不是隐藏列表

def read_valid_lines(path):
    with open(path, encoding="utf-8") as stream:
        for line in stream:
            value = line.strip()
            if value:
                yield value
for value in read_valid_lines("input.txt"):
    print(value)

需要注意:

  • 生成器通常只能消费一次;
  • 代码执行到 yield 才暂停,并在下次迭代时继续;
  • with 中的资源会在生成器退出或被关闭时释放;
  • 若确实要重复随机访问,应显式转成列表,并接受内存开销。

*args**kwargs 与拆包

def configure(name, *layers, enabled=True, **metadata):
    return {
        "name": name,
        "layers": layers,
        "enabled": enabled,
        "metadata": metadata,
    }

options = {"enabled": False, "owner": "TEAM"}
result = configure("policy", 128, 64, **options)

函数签名应尽量显式。只有真正需要转发或扩展参数时才使用 **kwargs;否则拼写错误可能被悄悄收下。

容器拆包:

values = [1, 2, 3]
head, *middle, tail = values

卷积输出尺寸

对单个空间维度,普通卷积输出为:

$$ L_{\mathrm{out}} = \left\lfloor \frac{L_{\mathrm{in}} + 2P - D(K-1) - 1}{S} + 1 \right\rfloor $$

其中:

  • $L_{\mathrm{in}}$:输入长度;
  • $K$:卷积核大小;
  • $S$:步长;
  • $P$:padding;
  • $D$:dilation。

二维卷积对高和宽分别计算。PyTorch 验证示例:

import torch
from torch import nn

x = torch.randn(1, 1, 50, 50)
layer = nn.Conv2d(
    in_channels=1,
    out_channels=8,
    kernel_size=3,
    stride=2,
    padding=1,
    dilation=1,
)
y = layer(x)
print(y.shape)

旧公式缺少 floor 和 dilation,只在部分参数下碰巧正确。

批量媒体处理:先预演,再落盘

批处理最危险的部分不是图像缩放本身,而是文件覆盖、移动和异常恢复。推荐流程:

  1. 只读枚举输入,固定排序;
  2. 验证扩展名、尺寸和可解码性;
  3. 输出到新的临时目录;
  4. 核对数量和抽样结果;
  5. 最后再由人工决定是否替换原目录。
from pathlib import Path

source = Path("input_images")
files = sorted(
    path for path in source.iterdir()
    if path.suffix.lower() in {".jpg", ".jpeg", ".png"}
)

for index, path in enumerate(files):
    print(index, path.name)

不要让“生成视频”脚本顺便移动或删除原图。FFmpeg 调用也应检查退出码,并把输入帧率、输出帧率、编码器和像素格式显式写入项目记录。

UDP 与 TCP 的不同边界

UDP 保留数据报边界,但可能丢包、乱序或重复;TCP 可靠地传输字节流,却不保留消息边界。一次 send 不保证对应接收端的一次 recv

工程中的 TCP 消息至少需要一种分帧协议:

  • 固定长度;
  • 换行等分隔符,并处理转义/长度上限;
  • 固定长度头部携带正文长度;
  • 使用已经定义好边界的上层协议。

客户端还应设置连接和读写超时、限制最大消息长度,并处理 DNS 返回多个地址的情况。服务端不应把任意网络输入直接交给 pickle、shell 或解释器执行。

UDP 广播前则要确认:

  • 发送 socket 开启了广播选项;
  • 目标广播地址属于正确网段;
  • 防火墙允许所需的 UDP 端口;
  • 接收端绑定的接口与地址正确;
  • 业务协议能处理丢包和重复包。

包、__init__.py 与公开接口

模块是一个 .py 文件,包用于组织模块和子包。传统包通常含有 __init__.py

package_name/
├── __init__.py
├── core.py
└── tools/
    ├── __init__.py
    └── inspect.py

__init__.py 可以为空,也可以显式重导出稳定接口:

from .core import build_model

__all__ = ["build_model"]

__all__ 主要影响 from package_name import *,不会形成安全边界,也不会阻止调用者直接导入其他模块。不要在 __init__.py 中执行耗时训练、网络请求或依赖当前目录的副作用;导入包应该可预测。

Python 3.3 起支持没有 __init__.py 的 namespace package,适合由多个发行包共同贡献同一命名空间。普通单仓库项目不必为了“更新”而删除 __init__.py;是否采用 namespace package 应由打包结构决定。

相对导入只适用于包上下文:

# package_name/tools/inspect.py
from ..core import build_model

直接执行 python package_name/tools/inspect.py 可能没有正确包上下文;应从项目入口运行 python -m package_name.tools.inspect

浅拷贝与深拷贝

下面用 deepcopy 保存列表状态。它是安全的通用演示,但不是所有对象都需要深拷贝:

from copy import copy, deepcopy

state = {"position": [1.0, 2.0], "done": False}
shallow = copy(state)
deep = deepcopy(state)

state["position"][0] = 9.0
print(shallow["position"])  # [9.0, 2.0]:嵌套列表仍共享
print(deep["position"])     # [1.0, 2.0]

NumPy 数组、PyTorch 张量和包含文件/socket 的对象还有各自的复制语义。保存训练状态时,应明确需要的是 Python 容器副本、数组数据副本,还是从计算图分离的张量,例如 tensor.detach().clone()

生成器的完整迭代过程

生成器函数在调用时返回生成器对象,直到第一次迭代才开始执行函数体:

def simple_generator():
    print("produce 1")
    yield 1
    print("produce 2")
    yield 2
    print("done")

generator = simple_generator()
print(next(generator))
print(next(generator))

再次 next(generator) 会抛出 StopIteration。通常用 for 消费,由循环处理结束状态:

for value in simple_generator():
    print("received", value)

无限序列必须由调用者设置边界:

from itertools import islice

def fibonacci():
    left, right = 0, 1
    while True:
        yield left
        left, right = right, left + right

print(list(islice(fibonacci(), 10)))

生成器表达式使用圆括号,适合单次流式消费:

squares = (value * value for value in range(5))
print(list(squares))

sendthrowclose 可以与生成器双向交互,但控制流更难理解。现代异步任务通常优先使用明确的迭代器、async/await 或队列,而不是把复杂协程协议藏进普通生成器。

参数收集与调用拆包

形参顺序可以包含位置参数、可变位置参数、仅关键字参数和可变关键字参数:

def configure(name, *layers, enabled=True, **metadata):
    return name, layers, enabled, metadata

configure("policy", 128, 64, enabled=False, owner="TEAM")

调用时,* 展开可迭代对象,** 展开字符串键字典:

layers = [128, 64]
options = {"enabled": False, "owner": "TEAM"}
configure("policy", *layers, **options)

若显式参数和 **options 中出现同名键,调用会报重复参数错误。公共 API 不应把所有配置都塞进 **kwargs;显式形参更容易被类型检查、IDE 和调用者发现。

图片序列生成视频

旧 OpenCV 版本的核心思路可以保留,但需要检查空目录、解码失败、尺寸不一致和 writer 状态,并且不能移动原图:

from pathlib import Path
import re

import cv2


def natural_key(path: Path):
    return [
        int(part) if part.isdigit() else part.lower()
        for part in re.split(r"(\d+)", path.name)
    ]


def images_to_video(source_dir, output_file, fps=30.0):
    source = Path(source_dir)
    output = Path(output_file)
    if output.exists():
        raise FileExistsError(output)

    extensions = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}
    frames = sorted(
        (path for path in source.iterdir() if path.suffix.lower() in extensions),
        key=natural_key,
    )
    if not frames:
        raise ValueError("no readable image candidates")

    first = cv2.imread(str(frames[0]))
    if first is None:
        raise ValueError(f"cannot decode {frames[0].name}")
    height, width = first.shape[:2]

    writer = cv2.VideoWriter(
        str(output),
        cv2.VideoWriter_fourcc(*"mp4v"),
        fps,
        (width, height),
    )
    if not writer.isOpened():
        raise RuntimeError("video writer could not be opened")

    try:
        for path in frames:
            frame = cv2.imread(str(path))
            if frame is None:
                raise ValueError(f"cannot decode {path.name}")
            if frame.shape[:2] != (height, width):
                raise ValueError(f"frame size mismatch: {path.name}")
            writer.write(frame)
    finally:
        writer.release()

这个版本只读输入目录、拒绝覆盖已有输出,也不会为了凑编号而重命名原图。运行后仍要核对帧数、时长和抽样画面。

已有严格编号的图片也可直接交给 FFmpeg,并用 -n 拒绝覆盖:

ffmpeg -n -framerate 30 -i 'frames/%04d.png' \
  -c:v libx264 -pix_fmt yuv420p output.mp4

去除音频且不重编码视频:

ffmpeg -n -i input.mp4 -map 0:v:0 -c:v copy -an output-silent.mp4

-map 0:v:0 明确选择第一条视频流;如果输入含多条视频、字幕或附件,应先用 ffprobe 查看流并决定保留范围。

图片缩放与 SVG 转 PDF

输出使用不同文件名,避免覆盖原件:

from pathlib import Path
from PIL import Image

source = Path("input.jpg")
destination = Path("output-resized.jpg")
if destination.exists():
    raise FileExistsError(destination)

with Image.open(source) as image:
    resized = image.resize((240, 320), Image.Resampling.LANCZOS)
    resized.save(destination)

这里会强制变成指定宽高,可能改变宽高比。需要保持比例时用 thumbnail 或根据原始尺寸计算目标尺寸。

CairoSVG 已在项目依赖中时,可转换 SVG:

from pathlib import Path
import cairosvg

source = Path("figure.svg")
destination = Path("figure.pdf")
if destination.exists():
    raise FileExistsError(destination)
cairosvg.svg2pdf(url=str(source), write_to=str(destination))

SVG 可以引用外部资源;只转换可信输入,并在交付前检查字体、透明度和页面边界。

UDP 最小示例

单次发送和接收可以用标准库完成。地址、端口都是占位符:

import socket


def send_udp(host, port, message):
    payload = message.encode("utf-8")
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
        sock.sendto(payload, (host, port))


def receive_udp(bind_host, port, max_size=4096):
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
        sock.bind((bind_host, port))
        sock.settimeout(5.0)
        payload, peer = sock.recvfrom(max_size)
        return payload.decode("utf-8"), peer

广播发送需要显式启用广播,并使用当前网段的正确广播地址:

with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
    sock.sendto(b"discovery", ("BROADCAST_ADDRESS", 9999))

BROADCAST_ADDRESS 必须替换并核对。UDP 没有内建身份验证;发现协议不应把收到的任意内容当成可信指令。

TCP 长度前缀示例

TCP 是字节流。发送一条 UTF-8 消息时,可先发送固定长度头部:

import socket
import struct


def send_message(host, port, message, timeout=5.0):
    payload = message.encode("utf-8")
    if len(payload) > 1_000_000:
        raise ValueError("message too large")
    frame = struct.pack("!I", len(payload)) + payload
    with socket.create_connection((host, port), timeout=timeout) as sock:
        sock.sendall(frame)

接收端必须循环读取准确字节数,不能假定一次 recv 就得到完整内容:

def recv_exact(sock, size):
    chunks = []
    remaining = size
    while remaining:
        chunk = sock.recv(remaining)
        if not chunk:
            raise ConnectionError("connection closed mid-frame")
        chunks.append(chunk)
        remaining -= len(chunk)
    return b"".join(chunks)


def recv_message(sock, max_size=1_000_000):
    size = struct.unpack("!I", recv_exact(sock, 4))[0]
    if size > max_size:
        raise ValueError("message too large")
    return recv_exact(sock, size).decode("utf-8")

长度前缀只解决消息边界,不提供加密或认证。跨不可信网络应使用 TLS 或成熟的上层协议。

小结

Python 工程中的许多问题并非语法错误,而是边界假设错误:把引用当副本、把工作目录当包路径、把 TCP 当消息队列、把批处理成功当作原数据安全。把这些假设写成可验证的检查,代码会比增加更多“技巧”可靠。