2. Qiskit电路对象再审视
在上一节中,我们已经把“模块封装与复用”确立为整门课的主线。但要真正把电路封装成可复用模块,前提是先吃透被封装的对象本身——QuantumCircuit。很多初学者把 QuantumCircuit 仅仅当作“装门子的容器”,于是封装时只会机械地 append 一堆门,结果模块既不可参数化、也不可组合、更无法在更大电路里被正确展开。本节我们重新审视这个对象模型,重点厘清三件事:它是怎么被构造出来的、多个电路如何组合成一个更大的电路、以及参数化能力如何让同一个电路结构适配不同数值。这三项能力,正是后续所有封装手段(自定义指令、QuantumCircuit 作为子模块、参数化门、compose 与 append 的取舍)所依赖的地基。
一、构造:从寄存器到指令序列
一个 QuantumCircuit 在内部并不是“一张门表”,而是由若干层信息共同构成的:量子寄存器(QuantumRegister)、经典寄存器(ClassicalRegister),以及按顺序记录的指令(CircuitInstruction),每条指令包含一个操作(Operation)和它作用的量子位/经典位。理解这一点非常关键:电路是“指令序列 + 位资源”的组合体,而不是单纯的图形。封装时我们关心的“这个模块需要几个量子位、几个经典位、内部有哪些门”,本质上都是在读这些属性。
构造方式有多种,最常用的是直接声明寄存器再逐条添加:
from qiskit import QuantumCircuit, QuantumRegister, ClassicalRegister
qr = QuantumRegister(2, "q")
cr = ClassicalRegister(2, "c")
qc = QuantumCircuit(qr, cr)
qc.h(qr[0])
qc.cx(qr[0], qr[1])
qc.measure(qr, cr)
也可以直接用整数构造 QuantumCircuit(2, 2),此时 Qiskit 会自动创建名为 q、c 的寄存器。两种写法在功能上等价,但显式命名寄存器在封装场景下更可取:当模块被嵌入更大的电路时,寄存器名会成为位映射的依据,命名清晰能显著降低后续 compose 时“位对错”的风险。
值得强调的是,构造阶段就要想清楚模块的“接口”:它对外暴露几个量子位、是否需要经典位、测量是放在模块内部还是留给调用方。这些决定会直接影响后面封装成自定义指令时 num_qubits、num_clbits 的取值。
二、组合:compose、append 与张量积的分工
“组合”是复用最直接的体现:把已经写好的子电路拼进主电路。Qiskit 提供了几种语义不同的组合方式,混用会导致位序错乱或结构被意外展平,因此必须分清。
compose(other, qubits=..., clbits=...):把other作为一个整体贴到指定位置上,默认会保留other的内部结构(不展平),是“模块化拼接”的首选。append(instruction, qargs, cargs):把单个操作追加到电路末尾,qargs明确给出作用位。它更底层,适合把自定义指令或门对象挂上去。tensor/^:把两个电路做张量积,得到并排作用于不同位空间的新电路,语义是“并行组合”而非“顺序拼接”。
下面这段示例把两个两比特子电路顺序拼成一个四比特电路,并显式指定落位,避免依赖默认顺序:
from qiskit import QuantumCircuit
sub_a = QuantumCircuit(2, name="bell")
sub_a.h(0); sub_a.cx(0, 1)
sub_b = QuantumCircuit(2, name="rot")
sub_b.rx(0.3, 0); sub_b.ry(0.5, 1)
main = QuantumCircuit(4)
main.compose(sub_a, qubits=[0, 1], inplace=True)
main.compose(sub_b, qubits=[2, 3], inplace=True)
这里 inplace=True 表示就地修改 main;若不写,compose 会返回新电路而不改动原对象。这个细节在封装函数里极易踩坑——如果封装函数内部忘了接收返回值,调用方拿到的仍是未组合的电路。建议在模块封装中统一约定:要么全部 inplace=True,要么全部返回新对象,不要混用。
另一个常被忽视的点是位映射。compose 的 qubits 参数是一个“目标位列表”,其长度必须等于子电路的量子位数,顺序即子电路第 0、1、… 位依次落到主电路的哪些位上。封装成可复用模块时,这个映射关系应当由模块的接口约定固定下来,而不是让调用方每次猜。
三、参数化:让结构复用、数值可变
如果说 compose 解决的是“结构复用”,那么参数化解决的是“数值复用”。做法是把旋转角等数值替换为 Parameter 对象,电路在构造时只记录符号,等到需要时才绑定具体值:
from qiskit.circuit import QuantumCircuit, Parameter
theta = Parameter("θ")
ansatz = QuantumCircuit(2, name="ansatz")
ansatz.ry(theta, 0)
ansatz.cx(0, 1)
ansatz.ry(theta, 1)
bound = ansatz.assign_parameters({theta: 0.4})
print(ansatz.parameters) # {Parameter(θ)}
print(bound.parameters) # set()
这里有两个实践要点。第一,参数是“电路级”的共享对象:同一个 theta 被用在两个 ry 上,绑定一次即同时生效;若希望两个角独立,就应使用 ParameterVector("θ", 2) 或两个不同名的 Parameter。第二,assign_parameters 默认返回新电路,原电路保持不变,这正好符合“模板 + 实例”的复用范式:模板只构造一次,每次实验生成一个绑定后的实例。
封装模块时,参数化还带来一个接口设计问题:模块应当把哪些参数暴露给调用方?推荐做法是让模块的 parameters 属性自然携带全部未绑定参数,调用方通过名字或 ParameterVector 索引绑定,而不是在封装函数里硬编码数值。这样同一个 ansatz 模块既能用于训练循环中的多次前向,也能用于不同超参数的对比实验。
四、为封装做准备:需要盯住的几个属性
把上述能力落到“封装”这个目标上,实际编码时应重点检查电路对象的以下属性,它们构成了模块的元数据:
| 属性 / 方法 | 含义 | 封装时的用途 |
|---|---|---|
num_qubits / num_clbits | 电路占用的位数量 | 决定自定义指令的接口宽度 |
parameters | 未绑定的符号参数集合 | 决定模块对外暴露的可调参数 |
qregs / cregs | 寄存器及其命名 | 位映射与命名约定的依据 |
data | 指令序列(含 operation 与位) | 检查内部结构是否被意外展平 |
to_instruction() | 把电路转为可 append 的指令 | 封装为黑盒模块的关键一步 |
其中 to_instruction() 是承上启下的关键:它把整个电路压缩成一个 Instruction,之后既可以用 append 挂到别的电路上,也可以注册为自定义指令供全局复用。需要注意的是,含测量的电路转成指令后,经典位也会成为接口的一部分,调用方必须提供对应的 cargs,否则会报位数量不匹配。
qc.num_qubits、qc.parameters 和 qc.data 的长度,确认位数量、参数集合与指令条数符合预期。很多组合错误(如子电路被展平、参数被提前绑定)在这一步就能暴露。