从bug到收获:浅谈编程语言的深拷贝与浅拷贝概念
写在前面的
前几天在测试一个借助AI新写的程序中发现了一个bug,为了解决这个bug,我花了2个多小时判断bug,理解bug并解决bug。在AI流行的当下,如果编程者彻底丧失对代码的基础理解力,那么面对AI写的程序时很有可能忽视程序内部潜在的致命bug。从而产生错误的数据分析结果,影响项目的正常推进。因此无论是自我coding还是借助AI coding,把握每一次编程的机会判断、检查程序是否存在bug对于提升个人的底层编程能力和思维具有重要意义。接下来我会简单分享我在这次debug过程中发现的bug,并通过解决它的过程重新学习到的以前遗忘的知识。
my %endecs;
my %ennorm;
my %denorm;
open(FILE, "<", $sequence_file)
while(<FILE>){
chomp;
my @line = split/\t/;
my $region = join("_", (@line[($#line - 5)..($#line - 3)]));
my ($linker_mark, $domain_mark) = ($line[$#line - 2], $line[$#line - 1]);
my $protein_motif = $line[$motif_type{$mtf_type}];
if(($linker_mark * $domain_mark == -1) and ($linker_mark + $domain_mark == 0)){
if(($line[5] eq "+" and $linker_mark == 1) or ($line[5] eq "-" and $domain_mark == 1)){
$endecs{$region}{"CBD"}{$protein_motif} += 1;
$endecs{$region}{"CBD"}{"total"} += 1;
$endecs{"all-data"}{"CBD"}{$protein_motif} += 1;
$endecs{"all-data"}{"CBD"}{"total"} += 1;
}
elsif(($line[5] eq "+" and $linker_mark == -1) or ($line[5] eq "-" and $domain_mark == -1)){
$endecs{$region}{"CD"}{$protein_motif} += 1;
$endecs{$region}{"CD"}{"total"} += 1;
$endecs{"all-data"}{"CD"}{$protein_motif} += 1;
$endecs{"all-data"}{"CD"}{"total"} += 1;
}
}
elsif(($linker_mark * $domain_mark == 0) and ($linker_mark + $domain_mark == 1)){
if(($line[5] eq "+" and $linker_mark == 1) or ($line[5] eq "-" and $domain_mark == 1)){
$ennorm{$region}{"CBD"}{$protein_motif} += 1;
$ennorm{$region}{"CBD"}{"total"} += 1;
$ennorm{"all-data"}{"CBD"}{$protein_motif} += 1;
$ennorm{"all-data"}{"CBD"}{"total"} += 1;
}
elsif(($line[5] eq "+" and $linker_mark == 0) or ($line[5] eq "-" and $domain_mark == 0)){
$ennorm{$region}{"linker"}{$protein_motif} += 1;
$ennorm{$region}{"linker"}{"total"} += 1;
$ennorm{"all-data"}{"linker"}{$protein_motif} += 1;
$ennorm{"all-data"}{"linker"}{"total"} += 1;
}
}
elsif(($linker_mark * $domain_mark == 0) and ($linker_mark + $domain_mark == -1)){
if(($line[5] eq "+" and $linker_mark == 0) or ($line[5] eq "-" and $domain_mark == 0)){
$denorm{$region}{"linker"}{$protein_motif} += 1;
$denorm{$region}{"linker"}{"total"} += 1;
$denorm{"all-data"}{"linker"}{$protein_motif} += 1;
$denorm{"all-data"}{"linker"}{"total"} += 1;
}
elsif(($line[5] eq "+" and $linker_mark == -1) or ($line[5] eq "-" and $domain_mark == -1)){
$denorm{$region}{"CD"}{$protein_motif} += 1;
$denorm{$region}{"CD"}{"total"} += 1;
$denorm{"all-data"}{"CD"}{$protein_motif} += 1;
$denorm{"all-data"}{"CD"}{"total"} += 1;
}
}
}
close FILE;
my %endecs_count = %endecs;
my %ennorm_count = %ennorm;
my %denorm_count = %denorm;
上面的代码是我用perl语言处理一批序列数据时写的程序。在这个程序中,我先定义了3个哈希用于储存序列的多个特征,在完成了全部数据读取后,我将记录了序列全部信息的3个哈希复制给了另外3个新定义的哈希。在后续的程序中我将先对3个旧哈希按照一个方向进行计算处理,再对另外3个新哈希按照另外一个方向进行计算处理。两种计算处理方法不相同。但是在我测试程序的过程中,我发现先处理完旧哈希后,新哈希的值会在旧哈希的处理过程中连带着被运算。当然,通过debug的过程我知道了原因,这是因为的定义的哈希拥有多层键,当我使用等号的方式复制旧哈希给新哈希时,新旧哈希的第一层键被复制,但是第二层和第三层键及其对应的值被两个哈希共享。所以无论接下来对哪个哈希采取何种计算,都会影响另外一个哈希。造成这个原因的bug就在等号上,通过等号的方式复制哈希本质上是进行了一次浅拷贝(Shallow copy),如果要让两个哈希独立计算其键值且互不影响则要使用深拷贝(Deep copy)。所以正确的复制方法应该是
my %endecs_count = %{dclone(\%endecs)};
my %ennorm_count = %{dclone(\%ennorm)};
my %denorm_count = %{dclone(\%denorm)};
何为浅拷贝?何为深拷贝?
浅拷贝
定义:浅拷贝只复制哈希的顶层键值对。如果哈希的值是引用(例如数组或另一个哈希),则复制的只是引用本身,而不是引用的底层数据。因此,修改新哈希中引用的数据会影响原哈希。
特点:简单且快速,仅复制顶层数据。嵌套数据结构(如数组或哈希)的引用保持共享。
实现方法: 浅拷贝可以通过直接赋值或使用 %{} 解引用来实现。
my %original = (a => 1, b => [2, 3], c => {x => 4});
my %shallow_copy = %original; # 浅拷贝
# 修改浅拷贝中的数组
push @{$shallow_copy{b}}, 999;
print "@{$original{b}}\n"; # 输出: 2 3 999(原哈希也被修改)
深拷贝
定义:深拷贝会递归地复制哈希及其所有嵌套数据结构,生成完全独立的副本。修改新哈希的任何部分都不会影响原哈希。
特点:复制整个数据结构,包括嵌套的数组、哈希等。更耗费内存和计算资源。
实现方法:Perl 中没有内置的深拷贝函数,但可以使用Storable模块的dclone函数来实现深拷贝。
use Storable qw(dclone);
my %original = (a => 1, b => [2, 3], c => {x => 4});
my %deep_copy = %{ dclone(\%original) }; # 深拷贝
# 修改深拷贝中的数组
push @{$deep_copy{b}}, 999;
print "@{$original{b}}\n"; # 输出: 2 3(原哈希未受影响)
深拷贝和浅拷贝对比
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
浅拷贝在python中的实现
由于目前python成为了生物信息数据分析的主流编程语言,perl和python本质上是相通的,在perl上发现的问题可以迁移到python中进一步触类旁通。
- 使用内置方法或模块:
import copy
original = {'a': 1, 'b': [2, 3], 'c': {'x': 4}}
shallow_copy = original.copy() # 浅拷贝
shallow_copy['b'].append(999)
print(original['b']) # 输出: [2, 3, 999](原字典被修改)
深拷贝在python中的实现
- 使用 copy 模块的 deepcopy() 函数:
import copy
original = {'a': 1, 'b': [2, 3], 'c': {'x': 4}}
deep_copy = copy.deepcopy(original) # 深拷贝
deep_copy['b'].append(999)
print(original['b']) # 输出: [2, 3](原字典未受影响)
python和perl浅拷贝和深拷贝的异同
|
|
|
|
|---|---|---|
|
|
|
|
|
|
dict.copy()
copy.copy(), 切片/推导式
|
%new = %original
|
|
|
copy.deepcopy() |
Storable::dclone |
|
|
copy
|
Storable
|
|
|
copy())更直观
|
|
|
|
copy())更直观
|
Storable
|
|
|
copy 模块
|
Storable
|
为什么会有浅拷贝和深拷贝的区别
其实关于深拷贝和浅拷贝的bug,我曾在读博期间写python程序的时候遇到过。但是那会儿只是解决了bug没有去思考为什么会有深浅拷贝的区别。比如,我个人认为,平时复制数据结构时,默认是复制的数据和原始数据互不影响,因此完全使用深拷贝就可以了,为什么要出现浅拷贝的概念呢?当时没有详细了解相关的背景,因此没有对以前的bug加深印象。这次写perl程序的时候第二次发现了这个bug,因此有必要思考为什么在perl或者python等这些编程语言中复制哈希/列表/字典时有这两种概念。关于回答这个问题,就要回到计算机编程语言发展的历史中了。
浅拷贝提出的背景
浅拷贝的概念最早与支持复杂数据结构(如数组、结构体、对象或哈希)的编程语言相关,尤其是在 20 世纪 60-80 年代,编程语言(如 C、Lisp、Perl、Python 的前身等)开始广泛支持动态内存分配和引用机制。以下是推动浅拷贝概念提出的几个关键背景:
1.引用语义的兴起
背景:
- 在早期语言(如 Fortran 或 Algol),数据结构多为简单标量或固定大小的数组,复制通常是值的直接复制(相当于深拷贝,但数据简单,成本低)。
- 随着语言(如 Lisp、C、Smalltalk)引入动态内存分配和复杂数据结构(如链表、树、结构体),引用(或指针)成为管理内存的常见方式。引用允许数据结构通过内存地址共享,而不是每次都复制整个数据。
浅拷贝的起源:
- 引用语义意味着复制一个数据结构时,默认只复制其引用(地址),而不是底层数据。这种行为天然就是“浅拷贝”,因为它只涉及顶层结构(如指针或引用),不递归复制嵌套数据。
- 例如,在 C 中,复制一个结构体指针只复制指针本身,指向的内存保持共享。这种行为高效且符合内存管理的底层逻辑。
原因:
- 浅拷贝是引用语义的直接体现,符合硬件和操作系统对内存管理的实现方式。
- 它允许语言以最小成本支持复杂数据结构的复制,满足早期计算资源有限的环境需求。
2.性能和资源限制
背景:
- 在 60-70 年代,计算机的内存和 CPU 资源非常有限。例如,早期计算机可能只有几 KB 的内存,处理大型数据结构需要极高的效率。
- 复制复杂数据结构(如嵌套数组或链表)的全部内容(深拷贝)会消耗大量时间和内存,特别是在递归复制嵌套结构时。
浅拷贝的提出:
- 浅拷贝作为一种低成本的复制方式被提出,只复制顶层结构(如数组的指针或结构体的字段),而不递归复制嵌套数据。
- 例如,Lisp 中的列表操作或 C 中的指针赋值本质上是浅拷贝,允许快速创建数据副本。
原因:
- 浅拷贝显著降低了复制的性能开销,适合资源受限的环境。
- 它允许程序员在需要时手动管理嵌套数据的复制,提供了灵活性。
3. 编程需求的多样性
背景:
- 随着编程语言应用于更广泛的场景(如系统编程、脚本开发、数据处理),程序员需要处理不同类型的复制需求。
- 某些场景需要共享嵌套数据(如共享配置、缓存或全局状态),而其他场景需要完全独立的副本。
浅拷贝的角色:
- 浅拷贝满足了共享嵌套数据的场景。例如,多个数据结构可能需要引用同一块内存以节省空间或保持一致性。
- 它允许程序员显式控制哪些数据需要复制,哪些可以共享,增加了语言的表达能力。
为什么不默认深拷贝?
在语言设计之初,浅拷贝被优先考虑,而深拷贝作为次要选项,原因如下:
1.性能优先:
- 早期计算机资源有限,深拷贝的高成本不适合作为默认行为。
- 浅拷贝的低开销使其成为更实用的选择。
2.引用模型的逻辑:
- 引用语义是复杂数据结构管理的核心,浅拷贝直接复制引用,符合语言的底层设计。
- 深拷贝需要额外的递归逻辑,增加了实现复杂性。
3.共享需求的普遍性:
- 许多程序设计依赖共享嵌套数据(如全局状态、缓存),浅拷贝天然支持这种模式。
- 深拷贝会破坏共享,限制编程灵活性。
4.程序员控制:
- 语言设计者倾向于让程序员决定是否需要深拷贝,而不是强制昂贵的操作。
- 提供浅拷贝作为默认,深拷贝通过库或手动实现,平衡了灵活性和效率。

