作为 Github copilot 刚 beta 发布就重度使用至今的有二十多年码龄四十多岁还在写代码的码农,我觉得我有足够的说服力来阐述我对这个问题的理解。当然,口说无凭,本着「谁主张谁举证」的民事诉讼原则,我拿 github 自身的 copilot statistics API 看了一下我过去近三周的使用数据(我不是每天都写代码,所以有些天没数据),惊奇地发现我使用 copilot 比我想象得还要「勤劳」:
copilot 平均每天给我 472 个建议,我接受了 199 个,由此平均每天我有 388 行代码是 AI 完成的,最多的一天我接受了 936 行代码。一般一个优秀的程序员每周可以产生 1000 行左右的代码变更(Change Log),但过去三周平均每周光是 AI 就为我贡献了 2331 行代码变更,是优秀程序员的 233%。而且,这些代码都是我在工作之余写的,可以说从数字上看,我「仅仅」用业余时间就以极大的优势「完胜」优秀的程序员。
单纯从上面我个人的数据来看,AI 对开发效率有极其强劲的提升!但就像阴阳的边界是混沌的,「AI copilot 能提升开发效率么」这一问题我们很难用直接用能或者不能来回答。AI 就像满腹经纶的王语嫣,是个你我身边的流动图书馆,但如何提供合适的上下文把其中的知识调出来为我所用,则内藏很多玄机。
我们来分场景来分析 AI copilot 的效果。
很多时候,我们撰写的很大一部分代码是为了满足一些基本的需求,如框架的脚手架代码,新的数据结构的某些固定接口的实现,数据结构之间的转换等等。这些代码,用 AI copilot 非常方便,也很少出错,我来举几个例子。
在 Rust 下,我们经常要实现从某种数据结构转换成另一种数据结构的 From trait。实现这样的 trait 对精简主干代码非常有帮助,比如下面的把任意长度的数组转换成 redis RESP 协议数据结构 RespFrame 的 bulk string,因为 AI 洞悉两种数据结构的组成,所以可以轻松帮我完成:
impl<const N: usize> From<&[u8; N]> for RespFrame {
fn from(s: &[u8; N]) -> Self {
BulkString(s.to_vec()).into()
}
}
虽然这样的代码手写并不费劲,即便不用 AI,语言的 language server 也能把基本的脚手架代码补全,所以 AI 实际上就多完成了这句:
BulkString(s.to_vec()).into()
我们来看一个更加复杂的例子。在这个例子里,我们为 Matrix 实现了 Display trait,这是一个展示(输出)数据结构的接口。由于如何展示是一个比较特定的问题,AI copilot 第一次并不能完全猜对方向,这时候我们就需要使用足够的注释来告诉 copilot 给定输入,我们期待什么样的输出。目前的 GenAI 对于 few shot learning 的能力很强,你只要给一些典型的例子,它能够很好地给出对应的代码。所以,当我加上如下注释时:
// display a 2x3 as {1 2 3, 4 5 6}, 3x2 as {1 2, 3 4, 5 6}
就见证了「奇迹」:
impl<T> fmt::Display for Matrix<T>
where
T: fmt::Display,
{
// display a 2x3 as {1 2 3, 4 5 6}, 3x2 as {1 2, 3 4, 5 6}
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{{")?;
for i in 0..self.row {
for j in 0..self.col {
write!(f, "{}", self.data[i * self.col + j])?;
if j != self.col - 1 {
write!(f, " ")?;
}
}
if i != self.row - 1 {
write!(f, ", ")?;
}
}
write!(f, "}}")?;
Ok(())
}
}
这段代码即便对于一个优秀的程序员,也还是要花上2-5分钟才能撰写出来,AI copilot 可以秒杀。
我们再来看框架的脚手架代码。在 Rust 下,做性能测试,可以使用一个叫 criterion 的库来衡量某些功能的性能。你只要写好被测代码,AI copilot 可能瞬间帮你构建完相关的脚手架代码,准确率几乎是 100%。这类代码并不难写,但我们并不经常撰写,所以往往写之前需要再查查文档,看看示例代码,才能下手。AI copilot 可以帮助我们节省下看文档和示例的时间。
fn criterion_benchmark(c: &mut Criterion) {
let buf = BytesMut::from(DATA);
c.bench_function("v1_decode", |b| {
b.iter(|| v1_decode(black_box(&mut buf.clone())))
});
c.bench_function("v2_decode", |b| {
b.iter(|| v2_decode(black_box(&mut buf.clone())))
});
c.bench_function("v2_decode_no_buf_clone", |b| {
b.iter(|| v2_decode_no_buf_clone(black_box(&mut DATA.as_bytes())))
});
c.bench_function("v1_decode_parse_length", |b| {
b.iter(|| v1_decode_parse_length(black_box(&mut DATA.as_bytes())))
});
c.bench_function("v2_decode_parse_length", |b| {
b.iter(|| v2_decode_parse_length(black_box(&mut DATA.as_bytes())))
});
c.bench_function("v2_decode_parse_frame", |b| {
b.iter(|| v2_decode_parse_frame(black_box(&mut DATA.as_bytes())))
});
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);
AI copilot 最让我开心的地方是大大缩减了我构建单元测试代码的时间。大多是时间,只要我的被测代码足够清晰,那么测试代码就能非常准确地被生成出来。
比如下面是我撰写的解析 redis RESP 协议的部分代码。我希望能够从一个 BytesMut
中拆出一个 frame 并解析成正确的格式。
impl RespDecodeV2 for RespFrame {
fn decode(buf: &mut BytesMut) -> Result<Self, RespError> {
let len = Self::expect_length(buf)?;
let data = buf.split_to(len);
parse_frame(&mut data.as_ref()).map_err(|e| RespError::InvalidFrame(e.to_string()))
}
fn expect_length(buf: &[u8]) -> Result<usize, RespError> {
parse_frame_length(buf)
}
}
其中,map 的处理逻辑是这样的(你不必能看懂代码):
// - map: "%1\r\n+foo\r\n-bar\r\n"
fn map(input: &mut &[u8]) -> PResult<RespMap> {
let len: i64 = integer.parse_next(input)?;
if len <= 0 {
return Err(err_cut("map length must be non-negative"));
}
let mut map = BTreeMap::new();
for _ in 0..len {
let key = preceded('+', parse_string).parse_next(input)?;
let value = parse_frame(input)?;
map.insert(key, value);
}
Ok(RespMap(map))
}
根据这一系列的源码,当我撰写类似 respv2_map_with_real_data_should_work
这样的函数名之后,AI copilot 可以分析出我想测试 RespFrame::decode
,而它需要一个 BytesMut
参数,所以需要先构建一个 BytesMut
,然后因为要测试的限定词 “map” 和 “real_data”,于是它生成了一个有两个 item 的 RESP map 测试数据。最后,用解析出的结果和期待的结果对比。就这样,一步步地,AI copilot 以接近我们思考问题的方式,把代码撰写出来。
这样的单元测试代码,自己写可能只需要几分钟,然而架不住要写的测试会很多,单单 RESP 就有十多种数据结构,所以每种单独做一个简单的 happy path 的单元测试的话,半个小时甚至一个小时就过去了 —— 这可是非常昂贵的程序员的时间,浪费在这上面,还不如喝喝咖啡,思考思考人和宇宙的关系呢。
#[test]
fn respv2_map_with_real_data_should_work() {
let mut buf = BytesMut::from("%2\r\n+hello\r\n$5\r\nworld\r\n+foo\r\n$3\r\nbar\r\n");
let frame = RespFrame::decode(&mut buf).unwrap();
let items: BTreeMap<String, RespFrame> = [
("hello".to_string(), RespFrame::BulkString("world".into())),
("foo".to_string(), RespFrame::BulkString("bar".into())),
]
.into_iter()
.collect();
assert_eq!(frame, RespFrame::Map(items.into()));
}
AI copilot 的模仿能力很强,即便遇到它不会或者无法领会你意图的场合,你只要写一些正确的代码,它就能很快领悟你的思路。在我撰写了 simple_string
的解析后:
// - simple string: "+OK\r\n"
fn simple_string(input: &mut &[u8]) -> PResult<SimpleString> {
terminated(take_until(0.., CRLF), CRLF)
.map(|s: &[u8]| String::from_utf8_lossy(s).into_owned())
.parse_next(input)
}
当我给定如下的注释,并为函数起了个头后:
// - error: "-ERR unknown command 'foobar'\r\n"
fn error(input: &mut &[u8]) -> PResult<SimpleError> {
代码立刻被补全成:
// - error: "-ERR unknown command 'foobar'\r\n"
fn error(input: &mut &[u8]) -> PResult<SimpleError> {
terminated(take_until(0.., CRLF), CRLF)
.map(|s: &[u8]| String::from_utf8_lossy(s).into_owned())
.parse_next(input)
}
当然这样的代码不符合 DRY 精神,所以我手工把它重构了一下。随后我通过提供注释生成了 integer 的 parser:
// - integer: ":-1234\r\n", need to take care of the sign
fn integer(input: &mut &[u8]) -> PResult<i64> {
let sign = opt(alt(('-', '+'))).parse_next(input)?;
let num: i64 = digit1
.map(|s: &[u8]| {
let s = std::str::from_utf8(s).unwrap();
s.parse().unwrap()
})
.parse_next(input)?;
Ok(if sign == Some('-') { -num } else { num })
}
这个代码需求我没太说清楚(比如只会出现 -
号),需要修改并且精简一下:
fn integer(input: &mut &[u8]) -> PResult<i64> {
let sign = opt('-').parse_next(input)?.is_some();
let v: i64 = terminated(digit1.parse_to(), CRLF).parse_next(input)?;
Ok(if sign { -v } else { v })
}
注意使用 copilot 时合适的注释很重要,如果我不告诉它要 "take care of the sign",它很可能不会注意到要处理符号而给出错误的答案。
有了这样的代码打底后,当我给出 bulk string 的注释,主干代码立刻就浮现出来:
// - bulk string: "$6\r\nfoobar\r\n"
fn bulk_string(input: &mut &[u8]) -> PResult<BulkString> {
let len: i64 = integer.parse_next(input)?;
let data = terminated(take(len as usize), CRLF)
.map(|s: &[u8]| s.to_vec())
.parse_next(input)?;
Ok(BulkString(data))
}
我只需要处理它还不了解的错误处理:
// - bulk string: "$6\r\nfoobar\r\n"
fn bulk_string(input: &mut &[u8]) -> PResult<BulkString> {
let len: i64 = integer.parse_next(input)?;
+ if len == 0 {
+ return Ok(BulkString(vec![]));
+ } else if len < 0 {
+ return Err(err_cut("bulk string length must be non-negative"));
+ }
let data = terminated(take(len as usize), CRLF)
.map(|s: &[u8]| s.to_vec())
.parse_next(input)?;
Ok(BulkString(data))
}
有了这个新的认知,当我给定 array 的注释时,copilot 会学习 bulk string 的写法,然后妥善处理错误。它还知道调用我已经写好的 parse_frame
方法,来递归完成 array frame 的处理:
// - array: "*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n"
fn array(input: &mut &[u8]) -> PResult<RespArray> {
let len: i64 = integer.parse_next(input)?;
if len == 0 {
return Ok(RespArray(vec![]));
} else if len < 0 {
return Err(err_cut("array length must be non-negative"));
}
let mut arr = Vec::with_capacity(len as usize);
for _ in 0..len {
arr.push(parse_frame(input)?);
}
Ok(RespArray(arr))
}
我在使用的过程中,感觉 copilot 真的就像一个不断(从上下文)学习,充满求知欲的副手,我写的「对」的代码越多,它越有样学样,渐渐和我的思路合一。
其实从上文的代码中你也可以看到,AI 对生成固定格式的语法非常在行。比如它「看」了我各种解析函数的注释,了解了 RESP 的文法(虽然代码中没有完整的语法说明),他也能够生成复杂的结构,就像上面关于单元测试所述:
"%2\r\n+hello\r\n$5\r\nworld\r\n+foo\r\n$3\r\nbar\r\n"
更长的序列我也尝试生成,这是我在性能测试中让 copilot 生成的,注意这里我需要足够的注释和不断换行才能激发 copilot 生成更多内容(之后需要把这些换行都移除):
// resp frames covers all kinds of real-world redis requests and responses
// cmd 1: set key value
// cmd 1 response: OK
// cmd 2: get key
// cmd 2 response: value
// cmd 3: hset key field value
// cmd 3 response: ERR
// cmd 4: hget key field
// cmd 4 response: value
// cmd 5: sadd key member
// cmd 5 response: 1
const DATA: &str = "
*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n
*1\r\n+OK\r\n
*2\r\n$3\r\nGET\r\n$3\r\nkey\r\n
$5\r\nvalue\r\n
*4\r\n$4\r\nHSET\r\n$3\r\nkey\r\n$5\r\nfield\r\n$5\r\nvalue\r\n
*1\r\n-ERR\r\n
*3\r\n$4\r\nHGET\r\n$3\r\nkey\r\n$5\r\nfield\r\n
$5\r\nvalue\r\n
*3\r\n$4\r\nSADD\r\n$3\r\nkey\r\n$6\r\nmember\r\n
:1\r\n";
这样的东西,我自己撰写起来感觉非常费劲。
此外,各种对人脑来讲复杂晦涩的语法,copilot 生成起来也是轻车熟路,这是我让它生成的一个符合 pest 语法的对于 JSON 语义的 PEG parser:
WHITESPACE = _{ " " | "\t" | "\r" | "\n" }
json = _{ object | array }
object = { "{" ~ (pair ~ ("," ~ pair)*)? ~ "}" }
pair = { string ~ ":" ~ value }
array = { "[" ~ (value ~ ("," ~ value)*)? ~ "]" }
value = { string | number | object | array | "true" | "false" | "null" }
string = @{ "\"" ~ (char)* ~ "\"" }
char = { (!("\"" | "\\") ~ ANY) | "\\" ~ ("\"" | "\\" | "/" | "b" | "f" | "n" | "r" | "t") }
number = @{ "-"? ~ int ~ frac? ~ exp? }
int = { "0" | ASCII_NONZERO_DIGIT ~ ASCII_DIGIT* }
frac = { "." ~ ASCII_DIGIT+ }
exp = { ("e" | "E") ~ ("+" | "-")? ~ ASCII_DIGIT+ }
ASCII_DIGIT = _{ '0'..'9' }
ASCII_NONZERO_DIGIT = _{ '1'..'9' }
这个版本几乎满足了我的要求,只是 string 的处理有些问题,比如应该使用 “_” 而非 “@”(具体区别你不必关心),以及 json string 可以支持 unicode,所以我做了一些修改:
string = _{ "\"" ~ chars ~ "\"" }
chars = @{ (unescaped | escaped)* }
// any char not start with backslash or double quote
unescaped = { !("\\" | "\"") ~ ANY }
escaped = { "\\" ~ ("\"" | "\\" | "/" | "b" | "f" | "n" | "r" | "t" | "u" ~ ASCII_HEX_DIGIT{4}) }
这个工作如果让我从 0 到 1,我可能需要翻阅很多文档和案例,仔细斟酌才能写对,目测没有半小时搞不定,但 copilot 一次就给出了接近 80% 的答案,我只需要查漏补缺即可 —— 甚至,查漏补缺可以继续通过 copilot 完成。
目前,这几种情景我觉得 AI copilot 带给我帮助最大。
在使用 copilot 时,错误是不可避免的。我有时会对 AI 引发的错误抓狂,因为往往源头的问题会导致 AI 撰写的 unit test 也朝着同样的方向犯错,反而错误地得到 100% 的 UT 正确率。后来我把 copilot 当做工作伙伴,也就释怀了:我自己或者我的小伙伴也会犯这样的错误。所以,犯错并不可怕,只要我们对各种错误进行总结归纳,就能避免犯同样的错误。
我在写 redis RESP 解析器时,没有验证 copilot 帮我生成的关于 map 的示例。我猜想它从上下文中的 array 的示例中获得灵感,依葫芦画瓢写出了如下的例子:
// - map: "%2\r\n+foo\r\n-bar\r\n"
这里 map 的长度应该是 1,而不是 2。我却没有仔细检查,盲目相信 copilot,导致这个错误的需求示例进一步导致生成的代码,以及生成的测试都出现同样的错误。这样的代价很大,这个错误直到后续我添加真实场景的测试时才被揪出来。
对待这种问题,我们别无他法,唯有不盲信,仔细 review copilot 生成的代码。此外,即便 copilot 能 100% 帮助我们写测试代码,我们还要争取自己手写一些,另辟蹊跷,避免同质的思维在所有地方蔓延。
很多时候,当我对问题的理解有偏差,会导致我给出来的例子有偏差,直接导致 copilot 生成的代码有偏差。这种情况用 copilot 和自己手写代码其实并没有多大差别,都引入了 bug。应对这种情况我们唯有把握好真实的需求,反复求证。
很多同学抱怨 copilot 垃圾,提供不了太多有价值的帮助。殊不知 copilot 的价值往往是我们主导的,我们的水平决定了它的水平。就像一个平时逻辑思维混乱的人很难用合理的代码去表述清楚解决方案,如果你的顶层设计,数据流图含混不清,你的数据结构关系混乱,你已有的代码如意大利面条,也不去遵循软件开发或者语言本身的最佳实践(比如 Rust 从不去实现一些应该实现的系统 trait,就像上面提到的 From trait,而是将其逻辑糅杂在各个业务逻辑中),那么 copilot 大概率会沿袭你的写法,力图让生成出来的代码融入到本就糟糕的上下文中…
所以,copilot 是个放大器,它能放大优秀,也能放大平庸。
应对这种情况,我们需要修炼自己的内力,努力提升架构设计能力,尤其是数据建模的能力 —— 很多时候,当你有了优秀的数据结构和清晰的软件架构后,那么 copilot 如臂使指。在前 AI 时代,一个顶尖的程序员可以是平庸的程序员的真实效率的数倍到数十倍,而后 AI 时代,前者在 AI 的加持下,可以是后者的数十倍甚至数百倍。
这是产能上升必然会带来的幸福的烦恼。原本一天你能做 200 行代码变更,现在可以做 100 行自主变更,以及600行 AI 生成的变更,然而你不但要审阅这 600 行变更,更需要先从 AI 累计生成的 1500 行建议中挑出合格的 600 行。以前的工作主要是写作,现在是阅读,修改加融合。工作方式甚至工作性质都发生了变化,所以我们需要跟着变化,要着重在代码审阅上花费时间,防止 bug 从手边溜走。我前面说 copilot 能放大优秀,也能放大平庸,也体现在这里。优秀的代码脉络清晰容易理解,也容易审阅,而平庸的代码则充斥着一定程度的混乱,需要更多花在审阅上的精力。
然而,人的精力毕竟是有限的,此时,语言的差异就显得非常的重要。你会发现,由于通过 AI 大幅提升代码的生产率后,之前严格的,难于撰写的语言现在反而总体花费时间(撰写 + 找 bug)更少,而之前灵活,很容易撰写的语言,总体花费时间更多。
这是因为,像 Rust / Java / Scala / Kotlin / Swift / Typescript / Go 这样的语言,有严格的类型检查,如果生成的代码类型不匹配,则会在编译期报错 —— 甚至在你的 IDE 中,语言的 language server 会即时报错,这样就容易把 bug 扼杀在萌芽。而像 JavaScript / Python / Ruby 这样的语言,不少问题直到 UT 甚至运行时才会被发掘。我们知道,软件开发不可能不引入缺陷,但缺陷暴露的越早,我么你花费的时间越少。编译器做的事情越多,我们需要额外检查的事情就越少。
所以,AI 时代,编程语言的攻守之势异也。像 Rust 这样,门槛严苛,不但有严格类型检查,还有内存安全/并发安全检查(其实也是类型检查)的语言,反而容易成为 AI 时代的宠儿:因为人们会逐渐意识到,如果越来越多的代码由 AI 生成,我们从撰稿人成了主编,那么,语言的放心指数则成为了关键指标。
(题图,DALL-E3 生成,prompt:帮我画一张文章的题图:AI copilot 能提升开发效率么?要求 16:9)
以上代码示例大多出自我的《Rust 训练营》第 8 周的内容 —— 我介绍 winnow(一个 parser combinator,nom 的 fork)并用其撰写 redis RESP 协议的解析器。新版本的代码更少,效率是第 2 周我带大家手搓的代码的 8-10 倍:
在这个训练营的课程里,我不仅介绍语言本身,更多通过训练营帮助大家养成良好的开发素养,拓展思路,构建实际的开发能力,让自己成为一个优秀的工程师。同时,由于 AI 是未来的大势,掌握好 AI 工具,让 AI 成为你的优秀的放大器,我也在课程中身体力行地展示如何用好 copilot。