好的代码如何好
好代码如何好
易于阅读的代码是好代码
如何易于阅读
好代码在形式上要易于阅读、形式好看。不同的标准(google, Linux 内核等)有不同的要求
google 给出的一些反例

Linux 内核 的 8 tab

)
简单来说,要少用鲜为人知的缩写,保证名字易懂;一个函数只做一件事(退一步说,不同的事情分类在不同的区域),经可能保证结构清晰。
CSP 2023 结构体,洛谷题解的一些反例
中文命名

奇奇怪怪的缩写

易于证明的代码是好代码
不过可读性不是本文的重点,这些内容可能因人而异。但是,好代码的核心,一定是出错概率小。
做到出错概率为 0 是困难的,需要进行严谨的数学证明。
数学化证明
如何证明代码是对的?可以直接枚举所有状态,不过这样是很麻烦的。另一种方法是写出数学证明。我们可以借助证明助手。
在计算机科学和数理逻辑中,证明助手(英语:Proof assistant,亦称交互式定理证明器)是一类基于形式化逻辑的计算机软件工具,旨在辅助用户开发形式化证明(以数学上严格的方式构造、验证和管理证明过程)。[1]其核心功能是通过将命题转化为可计算的逻辑框架(如类型论或高阶逻辑),自动化检查每一步推理的正确性,从而确保证明的完整性与无矛盾性。此类工具通常结合了交互式编程环境,允许用户逐步构建证明并即时获得反馈,既可用于验证复杂数学定理的严谨性(如四色定理[2]、开普勒猜想[3]的计算机辅助证明),亦广泛应用于软件工程领域(如操作系统内核、加密协议的形式化验证)。[4]
引用自维基百科
在实际中我们不可能严谨地证明代码的正确性。但是这可以给我们带来一些启发
如何易于证明
程序从整体上看是顺序执行的,如果有 \(k\) 个操作,就有 \(k * (k - 1) / 2\) 个关系,我们很难同时处理这些关系(这点是我调 bug 时间最长的原因)。
因此我们要对操作进行分类,合并。我们应该将独立的代码放到不同的函数里。这样程序实际上形成了一棵树。每个操作是树的叶子。
以下是 chatgpt 对于我的 Tetris 俄罗斯方块 的代码 生成的树。
main
├── processGame ↻
│   ├── input  
│   ├── Tetromino::rotate ↻
│   ├── 旋转部分
│   │   ├── while topRowEmpty() ? ↻
│   │   │   └── shiftUp
│   │   └── while leftColumnEmpty() ? ↻
│   │       └── shiftLeft
│   └─ if (state is processing) ?
│       ├── dropTetromino
│       │       ├── while canPlace(...) ? ↻
│       │       │   └── canPlace
│       │       ├── place
│       │       └── eraseFullLines
│       │           └── for row = Bottom..0 ↻
│       │               ├── 检查是否满行 ?
│       │               └── 删除多余行
│       └── isOverFlow
              └── state = over
└── printBoard
↻ 表示循环,? 表示判断
对于一个节点( \(k\) 个操作的函数),有 \(k * (k - 1) / 2\) 个关系。因此函数包含的操作越少,关系越少,越易于证明。
Bug 的例子,好的代码的例子
T1 Tetris 俄罗斯方块
我的代码出现了三个 问题:
删除第 x 行后,x + 1 行替换第 x 行,不再进行检查,下一次检查变成了第 i + 2 行。
void erase_full_line(){
  for(int x = 23; x >= 0; x--){
    if(full(x)){
      do_down(x);
    }
  } 
}
原因是,for 循环导致执行顺序是
start
  ├── 检查第 23 行是否满了 ? 
  ├── 删除第 23 行
  ├── 检查第 22 行是否满了 ? 
  ├── 删除第 22 行
  .
  .
  .
  ├── 检查第 0 行是否满了 ? 
  └── 删除第 0 行
这里下面的操作对上面有错误的依赖,删除操作会改变数组的值。应该改为
start
  ├── 检查第 23 行是否满了 ? 
  ├── 在临时 board 上填充第 23 行
  ├── 检查第 22 行是否满了 ? 
  ├── 在临时 board 上填充第 22 行
  .
  .
  .
  ├── 检查第 0 行是否满了 ? 
  └── 在临时 board 上填充第 0 行
这样每个操作都是独立的,不会产生依赖。
代码实现是
void eraseFullLines(){
  Board erased{};
  int point = Bottom;  
  for(int row = Bottom; row >= 0; row--){
    if(!full(row)){
      erased[point--] = board[row];
    }
  }
  board = erased;
}
初始值写成了 24
for(int x = 24; x >= 0; x--)
原因:没有搞清楚数值的具体含义。应该把每个用到的数值写成常量,而不是直接用。可读性更好意味着更不容易出错。
const int H = 24, Bottom = 23;
for(int row = Bottom; row >= 0; row--)
输入时 return
void interact(){
  for(int t = 0; t < T; t++){
    // input;
    // rotate;
    if(not drop(tetromino, 0, y)) return;
    final_state = state;
  }
}
processGame ↻
  ├── input
  ├── Tetromino::rotate ↻
  ├── 旋转部分
  ├── dropTetromino
  ├── isOverFlow
  │     └── return! 
  └── 设为 final_state
这里上面的操作会影响到下面的,这是正常的。但是循环之间最好的独立的,而这里 return 影响到了下一行的 input。因此要注意循环内部的互相影响关系,在这里 over 只应该影响操作而不是读入。
前面的示例就是修改后的版本。
T2 Squadtrees
有一个 bug
错误使用了 compression
compression 是将压缩好的内容映射到大小。但是我直接访问了
compression[a],这里 a 是未压缩的。
这是因为没有搞清楚 map 的定义域,并且命名不清楚。chatgpt 给出的命名是 compressed_to_size。这样就表明了是将压缩好的东西映射到 size。
比较好的代码:
命名非常清晰,明显比这份他或者他队友写的更紧凑,没有许多 if 判断。
T3 Pushing Box
我的 bug
四个方向分别写了四个函数,导致修改时往往有几个没同时修改。因此合起来才能具有可扩展性。
比较好的代码:
#include <bits/stdc++.h>
#define rep(i,a,b) for (int i=a; i<b; i++)
#define pb push_back
#define mp make_pair
#define mitte (lewy+prawy)/2
#define fi first
#define se second
#define debug //
using namespace std;
typedef long long ll;
typedef long double ld;
int tab[20][20]; //box or no box
int h, w;
bool move (int x, int y, int vx, int vy, bool act=false)
{
	if (tab[x][y]==0) return true; //mozna przesunac
	
	if (x+vx<0 || x+vx==w) return false;
	if (y+vy<0 || y+vy==h) return false; //nie ma dokad
	
	if (tab[x+vx][y+vy]==0 || move(x+vx, y+vy, vx, vy, act)) 
	{
		if (act)
		{
			tab[x+vx][y+vy]=1;
			tab[x][y]=0;
		}
		return true;
	}
	return false;
}
void wypisz ()
{
	debug ("wypisuje plansze\n");
	rep(j,0,h)
	{
		rep(i,0,w) 	
		{
			if (tab[i][j]==1) debug ("X ");
			else debug ("%d ", tab[i][j]);
		}
		debug ("\n");
	}
}
int n;
int main ()
{
	cin>>h >>w;
	int d=1;
	while (w!=0 || h!=0)
	{
		cin>>n;
		memset(tab, 0, sizeof tab);
		int x, y;
		rep(i,0,n)
		{
			cin>>y >>x;
			tab[x][y]=1;
		}
		//wypisz();
		
		string s;
		int g, k;
		bool ok;
		cin>>s;
		while (s!="done")
		{
			cin>>g;
			ok = true;
			if (s=="down" || s=="up")
			{
				if (s=="up") 
				{
					k = -1;
					y = h-1;
				}
				else 
				{
					k=1;
					y=0;
				}
				rep(i,0,g)
				{
					debug ("move %d %d\n", y, k);
					//wypisz();
					rep(x,0,w) if (!move(x, y, 0, k)) ok = false;
					if (!ok) break;
					rep(x,0,w) 
					{
						move(x, y, 0, k, true);
					}
					
					y+=k;
				}
			}
			else
			{
				if (s=="left")
				{
					k = -1;
					x = w-1;
				}
				else
				{
					k = 1;
					x=0;
				}
				rep(i,0,g)
				{
					rep(y,0,h) if (!move(x, y, k, 0)) 
					{
						ok = false;
					}
					if (!ok) break;
					rep(y,0,h) move(x, y, k, 0, true);
					x+=k;
				}
			}
			
			//wypisz();
			
			cin>>s;
		}
		cout <<"Data set " <<d <<" ends with boxes at locations";
		vector<pair <int, int> > rob;
		rep(i,0,20) rep(j,0,20) if (tab[i][j]==1)
		{
			rob.pb(mp(j,i));
		}
		sort(rob.begin(), rob.end());
		for (pair <int, int> p: rob) cout <<" (" <<p.fi <<"," <<p.se <<")";
		cout<<".\n";
		d++;
		cin>>h >>w;
	}
}
大部分人都分四个方向讨论,这份代码使用 move 函数将四个方向合在一起。合起来看似是困难的,但是将单独让 x += k 变成 (+k, 0) 这样的向量,就使得合起来变得可行。
T4 结构体
比较好的代码:
命名非常详细,清楚地描述了函数或者变量做的事情。每个操作都分成函数分开写,结构清晰。
总结
好代码需要可读性强,一般是命名、结构清晰的(尽管在算竞里不常见)。同时用循环代替重复也是很重要的,哪怕第一眼看不可行。
好的代码要有清晰的逻辑,易证明是衡量逻辑是否清晰的标准。最严重的 bug 一般是太多的依赖导致的,多写函数合并相同内容,一个函数(或一个代码块)只做一件事情(尽管在算竞里不常见),才能让代码有清晰的逻辑。
                    
                
                
            
        
浙公网安备 33010602011771号