MoreRSS

site iconRamsay Leung修改

软件工程师,蚂蚁金服 - 微信 - AWS,使用Emacs 与Linux 
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Ramsay Leung的 RSS 预览

重新造轮子系列(三): HTML Selector

2025-03-16 01:53:00

项目 GitHub 地址: Selector 1

1 前言

以前写爬虫的时候,必不可少的一个工具就是 HTML selector, 就是用于匹配指定的 HTML 标签。

毕竟爬虫的本质就是找出需要的标签里面的内容,然后解析出来。

而 selector 主要有两个流派,一个是 CSS selector 2, 另外一个是 XPath selector 3 ,本质都是通过某种语法来匹配指定的标签,区别只是一个用的是 CSS 的语法,另外一个是 XML 语法.

这次我们就来写个基于 CSS 语法的 Selector, 来深入理解下 HTML 的 DOM 模型

2 DOM

写过前端的朋友应该都知道,前端代码主要是由所谓的三剑客组成的:HTML + CSS + JavaScript, 其中的三剑客各司其职,相互配合。

HTML 负责内容展示, CSS 负责布局和样式,而 JavaScript 是负责用户与页面之间的动态交互。

而对于如下的 HTML 代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<html>
  <head>
    <title>Example</title>
  </head>
  <body>
    <h1>Title</h1>
    <blockquote id="important">
      <p>Opening</p>
      <p>Explanation</p>
      <p class="highlight">Warning</p>
    </blockquote>
    <p>Closing</p>
  </body>
</html>

浏览器会将其进行解析,并生成名为 Document Object Model(DOM) 的数据结构,听着好像很玄乎,但本质就是一棵多叉树 (Multiway Tree):

知道 DOM 是多叉树, 我们就可以写出简化版本 DOM 的数据结构了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
export interface DomNode {
    type: string;
    name?: string;
    attribs?: {
        id?: string;
        class?: string;
        [key: string]: string | undefined;
    };
    children?: DomNode[];
    data?: string;
    parent?: DomNode;
}

一个节点可能有多个子节点 (children?) 或者一个父节点 (parent?), 也可能都没有,所以标记成 ?(optional);

一个节点可能有多个属性 attribs.

而节点的=type= 可能是 tag, text, comment, script, style, 而对于 textcomment 类型的节点, name 也是为空的.

这个 DOM 结构只是我们的简化版本,完整的 DOM 还有很多的属性和回调函数,详情可以查看文档: Document Object Model (DOM)

3 BFS vs DFS

理解到 DOM 的本质是个多叉树之后,我们很快就能意识到, selector 本质也就是遍历多叉树,找到符合要求的所有节点, 比如按 tag 名来匹配,按 id 来匹配,按 class 来匹配等等。

而用于遍历多叉树的常用算法就是广度优先搜索(Breadth First Search, BFS)和深度优先搜索(Depth First Search, DFS)

通常来说,BFS 和 DFS 都能完成多叉树遍历,时间复杂度也是相同的,BFS通常使用一个 queue 来记录遍历待节点,所以会使用更多的内存,但是能找到最短路径;而 DFS 通常使用递归,如果遇到个循环图,就会 StackOverflow,无法找到结果。

因为我们明确知道 DOM 是个多叉树(有向无环图),所以我们就使用 DFS 来遍历查找。

4 Strategy 设计模式

分析好问题之后,我们的实现也差不多能出来了, 按 tag 名来匹配,无非是 domNode.name === tagName; 按 class 来匹配, 即 domNode.attribs.class=== class.

为了解耦和易于扩展,我们可以使用个策略设计模式(Strategy Design Pattern 4).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
interface Selector {
    match(node: DomNode): boolean;
}

const findByTagName = (tag: string): Selector => ({
    match: (node: DomNode): boolean => {
        return node.name.toLowerCase() === tag.toLowerCase()
    }
});

const findById = (id: string): Selector => ({
    match: (node: DomNode): boolean => {
        return node.attribs.id === id;
    }
})

const findByClass = (clazz: string): Selector => ({
    match: (node: DomNode): boolean => {
        return node.attribs.class === clazz;
    }
});

然后遍历节点的时候,只需要判断 Selector 是否符合要求,而具体的匹配条件则由 selector 决定:

1
2
3
const isMatch = (node: DomNode, selectors: Selector[]): boolean => {
    return selectors.every(selector => selector.match(node));
}

这样的话,要增加一个根据属性keyValue值的匹配条件也是非常容易的, 如 div[align=center], 即匹配属性 align 和value 为 center:

1
2
3
4
5
const findByAttributes = (key: string, value: string): Selector => ({
    match: (node: DomNode): boolean => {
        return node.attribs[key] === value;
    }
})

5 测试验证

DFS + Strategy design pattern 就实现了一个基础的 CSS Selector, 我们自然需要测试验证下是否正确:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
describe('HTML selector testsuite', () => {
    const HTML = `<main>
  <p>text of first p</p>
  <p id="id-01">text of p#id-01</p>
  <p id="id-02">text of p#id-02</p>
  <p class="class-03">text of p.class-03</p>
  <div>
    <p>text of div / p</p>
    <p id="id-04">text of div / p#id-04</p>
    <p class="class-05">text of div / p.class-05</p>
    <p class="class-06">should not be found</p>
  </div>
  <div id="id-07">
    <p>text of div#id-07 / p</p>
    <p class="class-06">text of div#id-07 / p.class-06</p>
  </div>
</main>`

    it.each([
        ['p', 'text of first p'],
        ['p#id-01', 'text of p#id-01'],
        ['p#id-02', 'text of p#id-02'],
        ['p.class-03', 'text of p.class-03'],
        ['div p', 'text of div / p'],
        ['div p#id-04', 'text of div / p#id-04'],
        ['div p.class-05', 'text of div / p.class-05'],
        ['div#id-07 p', 'text of div#id-07 / p'],
        ['div#id-07 p.class-06', 'text of div#id-07 / p.class-06']
    ])('test select %s %s', async (selector, expected) => {
        const doc = htmlparser2.parseDOM(HTML)[0];
        const node = select(doc, selector);
        const actual = getText(node);
        expect(actual).toBe(expected);
    })
})

使用 Jest 框架编写了如上的单元测试用例, unit test 都通过了,完工.

值得一提的是,这种相同的验证逻辑, 但是输入多个不同的参数以验证不同case的做法,叫做 Parameterized Test

我在《测试技能进阶系列》的第二篇也曾经介绍过: Parameterized Tests

6 总结

这个简单的 CSS Selector 全部代码仅有 103 行, 但麻雀虽小,五脏俱全,功能还是齐备的:

1
2
3
4
5
6
7
8
> tokei simple-selectors.ts
===============================================================================
Language            Files        Lines         Code     Comments       Blanks
===============================================================================
TypeScript              1          131          103            9           19
===============================================================================
Total                   1          131          103            9           19
===============================================================================

所以标题也可以修改成 100 行代码实现一个简单的 CSS Selector :)

如果细看实现,还是有不少的优化之处的,比如 parseSelector 函数可以实现得更优雅些,以便进一步扩展支持其他的语法。

另外就是目前支持的都是所有 selector 完全匹配的情况,即 and, 但是目前不支持 or 的功能,即类如: h1,h2,h3, 可以匹配 h1, h2, 或者 h3.


如果想要看下较完整版本的 CSS Selector, 可以看下我六年多前我用 C++ 实现的版本, 实现从字符串解析并生成 DOM, 再实现 CSS 解析器,纯正的 OOP 风味。

当时初学 C++, 这个算是我早期写得比较大的 C++17 项目,核心代码大概1000行,还有几百行的单元测试。

现在再翻看自己的代码,会惊讶于当时自己代码写的工整,可谓是有板有眼,像极了书法初学者写的楷书。

这本砖头书读过, 其他的C++书籍, 如, , 也读过, 感觉不把书中的内容实践下, 很容易遗忘。

但是日常的工作内容并不会涉及底层网络服务, 一切底层细节内容都被框架给包掉了, 开发的主力语言是Java, 也不会使用到C++.

因此决定创造个机会实践下这些知识,最终决定只用C/C++内置函数库实现。

的确所有工具都是用C/C++内置函数库实现的,甚至测试框架还是自己用宏实现的.

只是我未曾想到的是,写了这段话后不足一年,C++就成为了我下一家公司干活的主力语言; 而现在,我又在重新写 Java, 着实是「白衣苍狗」。

回到本系列的目录

重新造轮子系列(二):文件备份

2025-03-03 03:57:00

项目 GitHub 地址: File Backup

1 前言

既然我们已经有单元测试框架来测试软件了,我们肯定不想已经写好的代码丢失掉。

对于重要的文件,一个必不可少的功能肯定是备份, 这样在丢失文件之后可以重新恢复。

今天我们就来写个简单的文件备份软件,类似 Git 这样的版本系统可以当作是高级版本的文件系统,因为它还支持切换到不同版本,对比版本间的差异等等功能,而我们不打算实现一个版本管理系统,只实现基础的文件备份功能。

2 实现思路

2.1 校验文件是否变更

我们不可能备份都将所有的文件备份一次,这样做效率太低了,我们应该只备份发生变更的文件,那么如何高效地判断文件是否发生变更呢?

最简单粗暴的方式是把文件读取出来,然后与以备份的文件作对比,但是这样的效率太低,并且算法复杂度是: O(N), 即运行时间是随着文件内容增长而增长的,文件越长,对比越慢。

最优算法的复杂度是 O(1), 我们希望可以通过常数时间内比较完文件内容。

我们可以使用 密码哈希算法(Cryptographic hash algorithms), 来实现判断文件是否发生变更,它有两个显著的特征:

  1. hash 函数的结果是定长,不会因输入变化而增加或减少
  2. 只要输入的任意bit生成变更, hash 函数生成的结果都会不一样

因此我们可以将文件的内容使用密码哈希函数如 sha1 来hash, 通过比较两次的哈希结果是否一致来判断文件是否发生变更。

2.2 判断文件是否被备份

判断文件是否被备份就很直接了,只需要看下当前文件是否在目标路径存在。

再结合上文提到的,只备份内容发生变更的文件,那么我们可以使用哈希函数的结果作为目标路径的备份文件名。

假设有文件 src/a.txt, 它的文件内容的哈希结果是 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8, 那么我们使用哈希值作为文件名备份到 dst, 即 dst/86f7e437faa5a7fce15d1ddcb9eaeaea377667b8.

对于文件 a.txt, 只需要判断 dst 是否存在 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8, 就知道 a.txt 是否被备份;

更巧妙的是,如果的 a.txt 文件内容发生变化,那么它的哈希值就一定不再会是 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 那么查找文件不存在,也可以当作是未备份,直接重新备份。

下面的序列图就是low level design:

2.3 性能优化

备份涉及到非常多的文件IO操作,而IO恰恰就是 Nodejs 最擅长的领域, 毕竟曾经的 NodeJS 还有个项目叫做 io.js.

NodeJS 的异步IO是基于 libuv, 但是我们不需要支持使用 libuv 的API, 只需要把文件相关的操作封装在 Promise 里面,NodeJS就会帮我们在处理底层的 IO 调度, 尽可能地并发处理IO, 避免阻塞.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
export const hashExisting = (rootDir: string): Promise<PathHashPair[]> => {
    const pattern = `${rootDir}/**/*`;
    return new Promise((resolve, reject) => {
        glob(pattern)
            .then(matches => Promise.all(
                matches.map(path => statPath(path))
            ))
            .then((pairs: PathStatPair[]) => pairs.filter(
                ([path, stat]) => stat.isFile()))
            .then((pairs: PathStatPair[]) => Promise.all(
                pairs.map(([path, stat]) => readPath(path))))
            .then((pairs: PathContentPair[]) => Promise.all(
                pairs.map(([path, content]) => hashPath(path, content))
            ))
            .then((pairs: PathHashPair[]) => resolve(pairs))
            .catch(err => reject(err))
    })
}

更多关于 Promise 的内容,可以查看这本书,它的解释非常到位.

2.4 测试文件系统

备份文件的设计我们已经分析和实现完了,接下来肯定是需要编写单元测试来测试我们的函数的,我们的文件备份涉及到非常多的文件操作,免不了要和文件系统打交道,包括创建文件,查找文件等等。

单元测试的其中一个原则就是要尽量屏蔽掉外部系统的依赖,以保证我们只聚焦在测试功能本身,文件系统的读写更像是集成测试需要做的事情, 各种操作也很容易把文件目录结构给搞乱,导致单元测试失败。

所以我们希望可以使用一个 mock object 来把文件系统 mock 掉,mock-fs 这个库做的就是这样的事情, 它可以把程序中的文件操作都 mock 掉,实际操作的是内存对象而非文件系统.

我们就可以在每个单元测试运行时,任意构造任何想要的文件目录,并且保证文件操作都是在操纵内存对象, 而不会直接作用到文件系统,保证单元测试的相互隔离。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import mock from 'mock-fs'

describe('checks for pre-existing hashes using mock filesystem', () => {
    beforeEach(() => {
        mock({
            'bck-0-csv-0': {},
            'bck-1-csv-1': {
                '0001.csv': 'alpha.js,abcd1234',
                'abcd1234.bck': 'alpha.js content'
            },
            'bck-4-csv-2': {
                '0001.csv': ['alpha.js,abcd1234',
                             'beta.txt,bcde2345'].join('\n'),
                '3024.csv': ['alpha.js,abcd1234',
                             'gamma.png,3456cdef',
                             'subdir/renamed.txt,bcde2345'].join('\n'),
                '3456cdef.bck': 'gamma.png content',
                'abcd1234.bck': 'alpha content',
                'bcde2345.bck': 'beta.txt became subdir/renamed.txt'
            }
        })
    })

    afterEach(() => {
        mock.restore()
    })
})

上面的代码就构造出下如下的文件目录:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
├── bck-0-csv-0
├── bck-1-csv-1
│   ├── 0001.csv
│   └── abcd1234.bck
└── bck-4-csv-2
├── 0001.csv
├── 3028.csv
├── 3456cdef.bck
├── abcd1234.bck
└── bcde2345.bck

3 使用示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
> tree .
.
├── backup.ts
├── check-existing-files.ts
├── hash-existing-promise.ts
├── main.ts
├── manifest.ts
├── reinvent_file_backup.org
├── run-hash-existing-promise.ts
├── stream-copy.ts
└── test
    ├── bck-0-csv-0
    ├── bck-1-csv-1
    │   ├── 0001.csv
    │   └── abcd1234.bck
    ├── bck-4-csv-2
    │   ├── 0001.csv
    │   ├── 3028.csv
    │   ├── 3456cdef.bck
    │   ├── abcd1234.bck
    │   └── bcde2345.bck
    ├── test-backup.js
    ├── test-find-mock.js
    └── test-find.js

5 directories, 18 files

> npx tsx main.ts -s . -d /tmp/backup -f json -v
[INFO] Destination directory ensured: /tmp/backup
[INFO] Starting backup from '.' to '/tmp/backup'
[INFO] Copied 8 files from /Users/ramsayleung/code/javascript/reinvent/file_backup to /tmp/backup
Backup completed in: 15.96ms
Backup completed successfully!

> ls -alrt /tmp/backup
total 88
drwxrwxrwt  23 root         wheel   736  2 Mar 17:06 ..
-rw-r--r--@  1 ramsayleung  wheel  1056  2 Mar 21:02 6bd385393bd0e4a4f9a3b68eea500b88165033b1.bck
-rw-r--r--@  1 ramsayleung  wheel  1649  2 Mar 21:02 8b0bc65c42ca2ae9095bb1c340844080f2f054da.bck
-rw-r--r--@  1 ramsayleung  wheel  9795  2 Mar 21:02 464240b6ef1f03652fefc56152039c0f8d105cfe.bck
-rw-r--r--@  1 ramsayleung  wheel   636  2 Mar 21:02 d0f548d134e99f1fcc2d1c81e1371f48d9f3ca0c.bck
-rw-r--r--@  1 ramsayleung  wheel   182  2 Mar 21:02 7fa1b33f68d734b406ddb58e3f85f199851393db.bck
-rw-r--r--@  1 ramsayleung  wheel   666  2 Mar 21:02 369034de6e5b7ee0e867c6cfca66eab59f834447.bck
-rw-r--r--@  1 ramsayleung  wheel  2533  2 Mar 21:02 02d5c238d29f9e49d2a1f525e7db5f420a654a3f.bck
-rw-r--r--@  1 ramsayleung  wheel  3512  2 Mar 21:02 964c0245a5d8cb217d64d794952c80ddf2aecca8.bck
drwxr-xr-x@ 11 ramsayleung  wheel   352  2 Mar 21:02 .
-rw-r--r--@  1 ramsayleung  wheel  1030  2 Mar 21:02 0000000000.json

为什么 file_backup 目录里面有 18 个文件,只备份了8个文件呢?因为 test 目录里面所有的文件都是空的,所以备份时就跳过了。

4 总结

我们就完成了一个文件备份软件的开发,功能当然还非常简单,还有非常多优化的空间,比如现在 src 目录的所有文件都会被平铺到 dst 目录,如果我们可以保存目录结构,那么就更好用了。

另外,使用哈希函数值作为文件名的确很巧妙,但是对于用户而已,如果不逐个打开文件,根本不知道哪个文件是对应哪个源文件等等。

如果想要实现一个更健壮易用的备份文件,可以参考下关于这 rsync 系列的文章 , rsync 是Linux 上非常流行的增量备份的文件,不仅可以备份本地文件,更可以把文件备份把远程服务器,非常强大。

回到本系列的目录

5 参考

软件工程师的软技能指北(六):谈薪篇

2025-02-18 07:06:00

1 目录

  1. 软件工程师的软技能指北(一):总览篇
  2. 软件工程师的软技能指北(二):事业篇
  3. 软件工程师的软技能指北(三):高效交流篇
  4. 软件工程师的软技能指北(四):简历篇
  5. 软件工程师的软技能指北(五):面试篇(暂时跳过, 后面再填)
  6. 软件工程师的软技能指北(六):谈薪篇

2 前言

打了这么多年工,要说最后悔和最遗憾的是什么,「拿到 Offer 之后没有好好和 HR 谈薪资绝对算是(甚至没有之一)」。

如果说面试前的简历准备,刷题,面试中应付面试官的各种提问以及面试之外的学习与积累是一场马拉松比赛的话,那么拿到 Offer 就意味着你已经成功完成征程的前九十九步了,而商谈薪资待遇就是最后一步,亦是最关键的一步。

如果这一步没有做好,前面的九十九步无论多完美都等于白费了,可谓是令人痛惜。

谈得成功的话,可能一年工资就多了一辆车了(至于是五菱MINI 还是保时捷911就看实力了)。

3 认知

回想我之前曾经关于「谈薪资」的错误认知,可谓是非常经典,可惜当初没有师长可以为我指正。

3.1 学生思维

刚毕业时的「这是家大公司,我是来学东西的,开什么薪资都可以」, 这个可以算是典型的「学生思维」,甚至出发点就是错的。

工作的根本目的就是「为了赚钱养活自己,过上更好的生活」,而不是去企业里面学习,「学习新本领」也只是「打工赚钱」的副产品而已。

经历过各种残酷的「末位淘汰」后我就发现,企业招「应届生」进去也不是因为看中你潜力大啥的,而是需要你这个年龄段的员工, 企业也有各种政府下达的就业指标,而招你进去也不是让你学东西的,而是让你干活的。

最明显的佐证就是,我所经历过的大公司每年的「末位淘汰」会有很多的应届生被打低绩效, 毕竟刚出校门的学生在能力,经验等方面是肯定是不如老员工的。

「末位淘汰」从来都不是激励你学习的,而是让你多干活的。

既然公司招聘员工是为了公司利益的最大化,员工自然就要最大化自己的利益。

3.2 心理负担

不要把公司 拟人化 ,不要觉得和公司「谈判」不好,因此产生心理负担,公司只是众多利益相关的人组成的共同体而已。

3.3 不愿意承担风险

诚然, 现在整体就业大环境肯定不如2010-2020年的黄金十年,各种裁员降薪的消息层出不穷, 但如果持有「能找到个工作就不错了,还谈什么薪资」的认知,可能很难去HR谈薪资, 毕竟你潜意识就已经选择接受 Offer, 除非是 HR 的报价是远低于你的底线.

须知,收益永远是风险挂钩的,你永远不可能有高收益,零风险的投资标的。

3.4 默认公平幻想

「工资由能力决定,相信公司会公平对待,肯定可以拿到对应的薪资,不需要自己去谈」

我曾经也持有种这样的「幻想」,以为凭借面试的表现折服了面试官,他自己会帮我争取, 甚至给我一个好的薪资.

首先,在较大的公司,面试官都是无法决定或者甚至知道薪资的,会有专门的 HR 团队来和你的谈判薪资的;

其次,你也很难期望面试官能把技术面试的结果如实呈现给非技术出身的 HR.

总而言之,「有枣子没枣子打一棒就好了」,争取了不一定要有,但是不争取绝对不会有人给你加工资的.

4 风险收益分析

我用博弈论模型来简单分析下分析公司和求职者的策略互动过程中风险与收益, 用收益矩阵来表示双方的选择和收益。

假设公司有三种策略:

  1. 接受谈薪(给更高薪资)
  2. 拒绝谈薪(维持原 Offer)
  3. 撤回 Offer

求职者也有三种策略:

  1. 不谈
  2. 温和谈判
  3. 强硬谈判

分析策略:

  • 不谈薪(保守策略)
    • 公司不会撤回 Offer,但求职者没有额外收益。
  • 温和谈判(合理博弈)
    • 60% 机会加薪 5W,40% 机会薪资不变,公司很少会撤回 Offer(5%)。
  • 强硬谈判(高风险高回报)
    • 50% 机会加薪 10W,30% 机会薪资不变,但 20% 可能失去 Offer。

收益矩阵:

公司接受 (+5W) 公司拒绝 (0W) 公司撤回 (-5W)
不谈 (30W) 30W 30W 30W
温和谈判 (35W) 35W(60%) 30W(40%) 30W(5%)
强硬谈判 (40W) 40W (50%) 30W (30%) 5W (20%)

纳什均衡:

  • 如果求职者选择温和谈判,公司最优策略是接受或拒绝,而非撤回 Offer,因为撤回 Offer 也意味着公司损失招聘成本。
  • 如果求职者选择强硬谈判,公司可能更倾向于撤回 Offer,因为不愿承担过高的薪资成本。
  • 温和谈判是最优策略,因为它在风险和收益之间达到了较好的平衡, 既有提升空间,又降低 Offer 撤回风险.

也就是对于求职者来说,如果你选择谈判,谈判失败,你的结果大概率也是接受原 Offer而已,而一旦谈判成功,你将会获得额外的收益。

可以说在接 Offer 谈判时 风险低, 收益中, 甚至高 , 而不谈薪是最保守的策略,但也是收益最低的.

5 公司的策略

公司的终极目的肯定是为了创造营收,而在招聘中直接目的是希望可以招到想要的,能干活的员工。

每个职位肯定都是有级别,不同级别有不同的工资范围,但是级别只是定义了上下限,拿到上限和下限之间可谓有天渊之别。 而且,绝大部分职位,都不会给上限工资,人人都给上限,工资预算肯定超标了。

HR 给你 offer 数字之前,要考虑几个因素:

  1. 公司招人有预算,能用低价招到的,就肯定不会出高价
  2. 你可能会讨价还价,所以不能直接给上限,要留有提价的余地
  3. 如果工资太低,你可能一怒之下就拒了。

6 求职者的手牌

虽然求职者与公司谈判过程中处于相对弱势地位,但是这并不意味着我们完全没有牌可打,所以我们要利用好手上的牌。

6.1 公司内部的助力

说起来可能难以置信,虽然求职者可能与公司的人还毫无联系,但是求职者会在公司内有相当的助力,因为基本上你接触到的公司每个人都是想你接 offer 的.

对于公司来说,在你身上已经花费了相当的成本,HR 筛选简历,安排面试官面试,2-4轮的面试,都要花费面试官与HR时间的,公司付给员工工资,这些时间都是要算钱的。

而一旦你拒绝,这些花费的时间成本都会成为沉没成本。

对于 HR 来说,她们也是有考核指标的,招聘人数也是她们的考核指标,所以她们也有强烈的动机尽快招到人。

而对于招聘你的团队老板来说,肯定是希望赶紧招到人来干活的,工资多少他不关心,反正出的又不是他的钱。

所以这些人都是希望你能接 offer 的潜在助力。

6.2 多拿Offer

拿到多个的 offer,这个会成为你和 HR 谈判的最大底气,你就不会担心谈崩了,这家给不了,了不起去下家;如果多个公司愿意相互竞价,你就能渔翁得利,利益最大化。

对于公司来说,肯定是希望招到能力强,能干活的候选人,但是怎么去量化能力强呢,即便候选人通过了面试,也不能说明他们能力一定能胜任。

但是如果你能通过多个公司的面试,对于HR来说,相当你额外再通过了十几二十轮的面试, 从概率来说,你是没能力的候选人的机率大大降低,既然大家都说你好,那么他们就有更强烈的意愿与你洽谈。

6.3 薪资是底牌

上文提到,公司的策略是希望以尽量低的薪资来招到人,那么两个非常重要的参考标准:

一个就是别的公司给你开的工资,另外一个就是你现在的工资,

假如你现在的工资是20W 一年, 那我就只给你加个20%, 最多给加个30%, 既然你20W都愿意干,为什么我要开高价.

所以你现在的薪资就是你的底牌, 千万不要给人看到, 不然就定死上限了。

不然你拿了一次低工资,后面次次跳槽都要拿低工资,凭什么呢?真的是闻者伤心。

所以HR问你的时候,不要正面回答,尽量模糊化,以此争取最大利益。

比如HR换个问法,问你的预期薪资是多少?

这个问题非常坑,相当于给自己划定上限,如果你回答30W, 她就绝对不可能给你40W, 你可以回答说:我心里没个具体数字,但我对任何有竞争力的 offer 持开放态度.

一定要给的话,就给个模糊的范围, 比如阿里P7的水平吧。

7 谈判的手段

7.1 表达强烈的兴趣

“你想加入这家公司"这个前提是一切谈判的基础。

如果你表现出对加入这家公司兴趣寥廖, 那么即使你强如 Linus Torvalds, 也不会有HR会想和你谈薪资的,因为这个注定是不会有结果,她们当然也不会浪费时间。

所以你需要表示出对这家公司有强烈兴趣,无论是文化,工作方式,业务或者技术栈都与你非常契合,而你加入公司目前唯一的障碍就是没有拿到满意的薪资,这样HR才会非常乐意来为你争取。

你总要创造些条件给 HR, 她们才能为你争取更多。

所以无论你是否对这家公司感兴趣,起码你在谈薪资阶段都要表现出强烈的兴趣。

7.2 少说多听

求职者在谈薪资过程中是处于相对弱势的地位的,所以在谈判过程中要多听少说, 因为你的底牌不能被她们套出来,说得越多,不经意间漏出来的可能就越大。

而在电话中,当对方HR 试探性给出一个报价后,你都不要太快回应或者表达出来,因为对面的大概率也是谈判的老手,如果你表现得很欣喜, 那么她们就知道这个已经比你的底线要高了.

所以当听到报价之后,你可以适当地保持沉默,表示犹豫,然后让她们进一步加码, 即使她们不加码,你也用沉默表达了对报价的不满意.

7.3 多个公司竞价

如果只向一个公司谈 offer, 最多两轮,可能就到了摊牌的时刻了。

只有能让多个公司竞价,你才能让你的「未来雇主们」卷起来,实现利益最大化, 另外其他公司的报价也可以成为的谈薪资的底价.

比如你工资是20W, 你想要30W, 公司的HR就会挑战你,说你这30W 太高了,他们给不来, 其实也是换种说法说你不值30W.

但是如果A公司给了你30W, 那么你就有底气问 B 公司要高于30W 的薪资, 毕竟这是市场对你的认可.

假设B 公司给了你35W, 那么你就可以回头向A 公司说,你拿到个35W 的offer, 让A公司提高下标准, A 公司可能给到个37W,

然后你再回到 B 公司说:

我真的很想加入贵公司,和你们一起工作,只是A公司给了37W, 着实很为难,我真的很加入你们, 你们可以再匹配一下嘛?

B 如果能给到40W, 再把同样的话和A 说一次, 两家公司工作竞价两次, 就相当于谈了4次了.

如果手握两家以上公司的 offer, 那么这个可以再用到其他公司身上,所以这个就要求你要在差不多的时间拿到全部的 offer.

7.4 不要当场答应

无论 HR 给你的报价多么诱人,又或者这个是你梦寐以求的公司,都不要当场答应下来, 万一其他公司给你更好的报价呢,当场答应而后又反悔给人的印象太不好了, 要给自己留有余地。

如果 HR 要你马上给出答案,你也可以这样回应:

我非常珍惜贵公司提供的这个机会,我也非常希望加入贵公司,但是我意识到这个机会会对我的职业生涯产生重大影响, 我希望可以和家人商量之后再下决定。

8 总结

几轮谈薪之后, 你可能拿到满意或者仍然不满意的报价,你就可以根据自己的诉求选择公司了。

你不一定会选钱最多的,可能你还会考虑公司前景,职业前景,工作强度等因素,但是起码选择之后你不会因没有为争取更高的收入进行谈判而后悔。

此外,这个可能是你在这家公司最容易调薪的一次了。

一旦你加入公司之后,你就会受限于绩效,级别,部门业绩等各种限制,再也不会有打几通电话, 发几封邮件就能涨薪的美事了。

所以,从这个角度来说,谈薪资的电话可能是你最应该打,且性价比最高的电话(大概率也没有之一)

9 参考

重新造轮子系列(一):单元测试框架

2025-02-17 14:27:00

项目 GitHub 地址: Unit Test

1 前言

单元测试的重要性无须多言,它是保证项目质量的基石.

如果没有单元测试,根本没有信心说自己开发的功能是符合要求的,更没法在没有测试的保证进行项目的重构。

既然单元测试如此重要,今天就用Typescript来写一个简单但五脏俱全的单元测试框架。

2 历史

Javascript 比较流行的测试框架是 MochaJest , Java 具有统治地位的单元测试框架就是 JUnit, 现在做单元测试的框架, 一般称为 xUnit 家族, 而 xUnit 家族最早的成员, 不是 JUnit, 而是 SUnit(Smalltalk Unit), SUnit 的历史比 Junit 悠久得多, 大约在1994年的时候, Kent Beck, 也就是 Junit 的作者之一, 写了 SUnit, 而后才有了 JUnit (1998).

所以, 在 SUnit 的网站上, 极其显摆的写着”一切单元测试框架之母” (The mother of all unit testing frameworks).

事实上这是大实话 — 所有单元测试框架里面的名词术语, 都从 Sunit 来的, 如 TestCase, Fixture 等等.

3 实现

3.1 需求

先定义需求, 一个单元测试框架应该可以做到下面的事:

  1. 找到包含测试的文件
  2. 找到上述文件的测试 case
  3. 运行测试case
  4. 捕获测试运行结果,并输出所有的测试的运行总结

3.2 原型

一条 assert 语句就可以看作是最简单的测试 case, 对于测试case, 我们会有以下三种结果:

  • Pass: 运行成功, 测试结果与预期一致
  • Fail: 运行失败, 测试结果与预期不一致
  • Error: 运行测试过程中出现错误,我们不确定测试结果是否与预期一致

我们用以下的状态机来判断测试的结果:

我们把要实现的单元测试框架命名为 Hope, 根据上面的状态机,我们很快就可以写出一个原型:

单元测试用例接收一个函数作为参数,然后又集中运行所有的测试用例,并根据是否抛出异常以及异常的类型来判断结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import assert from 'assert';

const HopeTests: [string, () => void][] = [];
let HopePass = 0;
let HopeFail = 0;
let HopeError = 0;

// Record a single test for running later.
const hopeThat = (message: string, callback: () => void) => {
    HopeTests.push([message, callback]);
}

const main = () => {
    HopeTests.forEach(([message, test]) => {
        try {
            test();
            HopePass += 1;
        } catch (e) {
            if (e instanceof assert.AssertionError) {
                HopeFail += 1;
            } else {
                HopeError += 1;
            }
        }
    });

    console.log(`pass ${HopePass}`);
    console.log(`fail ${HopeFail}`);
    console.log(`error ${HopeError}`);
}

让我们编写点代码来测试下我们的「单元测试框架」:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Something to test(doesn't handle zero properly)

const sign = (value: number) => {
    if (value < 0) {
        return -1;
    } else {
        return 1;
    }
}

// These two should pass
hopeThat('Sign of negative is -1', () => assert(sign(-3) === -1));
hopeThat('Sign of positive is 1', () => assert(sign(10) === 1));

// This one should fail.
hopeThat('Sign of zero is 0', () => assert(sign(0) === 0));

// This one is an error.
hopeThat('Sign mispelled is erorr', () => assert(sign(sgn(1) === 1)));

// Call the main driver
main()

输出的结果是:

1
2
3
4
-> npx tsx dry_run.ts
pass 2
fail 1
error 1

我们的第一版单元测试框架 Hope 能正常运行了,不过它有几个问题:

  1. 它只是输出结果,但没有告诉我们是哪个单元测试成功了,哪个失败了,哪个报错,没法 debug
  2. 可变全局变量通常是有很大副作用的,我们应该把它封装起来
  3. 如果我们要测的函数里面,预期是要抛出 assert.AssertionError, 那么这个函数对应的测试用例就会被识别成失败的测试用例,也就是意味着我们不应该依赖 assert.AssertError 来作运行结果判断。

3.3 单例版本

我们可以将上面的测试代码地址封装在一个类里,然后通过单例设计模式(Singleton pattern)来确保只初始化出一个实例,这样就可以模拟出全局变量的效果,以此来解决前面的两个问题。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import assert from "assert";
import caller from 'caller';

class Hope {
  private todo: [string, () => void][] = []; // 记录所有需要运行的测试case.
  private passes: string[] = [];
  private fails: string[] = [];
  private errors: string[] = [];
  constructor() {
    this.todo = [];
    this.passes = [];
    this.fails = [];
    this.errors = [];
  }

  test(comment: string, callback: () => void) {
      // 通过caller 获取单元测试用例对应的文件名
    this.todo.push([`${caller()}::${comment}`, callback]);
  }

  run() {
    this.todo.forEach(([comment, test]) => {
      try {
        test();
        this.passes.push(comment);
      } catch (e) {
        if (e instanceof assert.AssertionError) {
          this.fails.push(comment);
        } else {
          this.errors.push(comment);
        }
      }
    })
  }
}
export default new Hope()

上面的代码又是如何实现单例模式的呢?依靠的是 Node 的两个运行机制:

  1. 在加载一个 module 的时候, 它就会解释并执行 module 的代码,这意味着它会运行 new Hope() 并且导出新创建的实例
  2. 那么是否意味着,每个 import 语句都会运行一下 new Hope() 呢? 并不是,Node会缓存导入的 module ,也就是说无论一个 module 被导入多少次, 它也只会执行一次代码。

只要导入 hope.ts 之后, 就可以使用 hope.test() 会注册单元测试用例,以便后续执行:

最后, 我们只需要再实现下输出测试结果的功能,既支持输出一行的简短结果,又可以支持详尽的输出. 如果需要的话,后续还可以支持输出JSON, CSV, 或者HTML 格式的结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
terse() {
  return this.cases()
    .map(([title, results]) => `${title}: ${results.length}`)
    .join(' ');
}

verbose() {
  let report = '';
  let prefix = '';
  for (const [title, results] of this.cases()) {
    report += `${prefix}${title}:`;
    prefix = '\n';
    for (const r of results) {
      report += `${prefix} ${r}`
    }
  }
  return report;
}

cases() {
  return [
    ['passes', this.passes],
    ['fails', this.fails],
    ['errors', this.errors]
  ]
}

万事具备,接下来就让我们写个函数验证下 Hope 框架:

1
2
3
4
import assert from "assert";
import hope from "./hope";

hope.test('Sum of 1 and 2', () => assert((1 + 2) === 3));

看起来挺不错,但是要怎么运行这个测试case 呢? 总不能每个测试文件都调用下 hope.run() 嘛? 人家 Jest 都可以自动扫描并运行测试用例。

让我们参考 Jest, 实现一个 Runner, 也实现动态加载测试文件.

import 不仅可以用来导入其他的模块,它可以当作是一个 async 函数,加载指定路径的文件, 如:

1
await import(module_path);

为了更好地控制我们的单元测试, 我们可以给 Hope 框架增加上一些命令行参数以控制其行为, CLI + Runner 的实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import minimist from 'minimist';
import { glob } from 'glob';
import hope from './hope';
import { fileURLToPath } from 'url';

const parse = (args: string[]) => {
  const parsed = minimist(args)

  return {
    // Default root directory is current directory if not specified
    root: parsed.root || '.',

    // Output format can be 'terse' or 'verbose' (default)
    output: parsed.output || 'verbose',

    // Array of test filenames if explicitly provided
    filenames: parsed._ || []
  }
}

const main = async (args: Array<string>) => {
  const options = parse(args);
  if (options.filenames.length == 0) {
    options.filenames = await glob(`${options.root}/**/test*.{ts,js}`);
  }

  for (const f of options.filenames) {
    const absolutePath = fileURLToPath(new URL(f, import.meta.url));
    await import(absolutePath);
  }
  hope.run()
  const result = (options.output === 'terse') ? hope.terse() : hope.verbose();
  console.log(result);
}

main(process.argv.slice(2))

我们默认会匹配所有以 test 为前缀的 ts 和 js 文件, 然后通过 import 导入, 因为 hope 是单例模式,所以所有的测试文件用的都是同一个实例, hope.run 就将注册的所有单元测试运行.

整个框架的工作流程如下:

大功告成,现在就来运行下我们的单元测试:

1
2
3
4
5
> npx tsx pray.ts
passes:
 file:///private/tmp/reinvent/unit_test/test_add.ts::Sum of 1 and 2
fails:
errors:

3.4 优化

3.4.1 增加运行时间

我们还可以记录每个测试用例的运行时间, 纳秒有点太小了,就精确到微秒即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
run() {
  this.todo.forEach(([comment, test]) => {
    try {
      const now = process.hrtime.bigint();
      test();
      const elapsedInMicro = (process.hrtime.bigint() - now) / (BigInt(1000));
      this.passes.push(comment + `, execution time: ${elapsedInMicro}us`);
    } catch (e) {
      if (e instanceof assert.AssertionError) {
        this.fails.push(comment);
      } else {
        this.errors.push(comment);
      }
    }
  })
}
1
2
3
4
5
> npx tsx pray.ts
passes:
 file:///private/tmp/reinvent/unit_test/test_add.ts::Sum of 1 and 2, execution time: 5us
fails:
errors:

3.4.2 增加 assert 函数

内置的 assert 函数只支持比较输入值是否为 True, 现代的测试框架都有很多的 helper 函数来简化 assert 语句,就让我们来实现下 assertEqual, assertThrows, assertMapEqual, assertSetEqual, assertArraySame 这几个函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
 * assert 抛出指定的异常
 */
export function assertThrows<T extends Error>(expectedType: new (...args: any[]) => T, func: () => void) {
    try {

        // expected to throw exception
        func();
        // unreachable
        assert(false, `Expected function to throw ${expectedType.name} but it did not throw`);
    } catch (error) {
        assert(error instanceof expectedType, `Expected function to throw ${expectedType.name} but it threw ${error instanceof Error ? error.constructor.name : typeof error}`);
    }
}

/**
 * assert 两个元素相等
 */
export function assertEqual<T>(actual: T, expected: T, message: string) {
    assert(actual === expected, message);
}

/**
 * assert 两个 Set 相同
 */
export function assertSetEqual<T>(actual: Set<T>, expected: Set<T>, message: string) {
    assert(actual.size == expected.size, message);
    for (const element of actual) {
        assert(expected.has(element), message);
    }
}

/**
 * assert 两个 Map 相同
 */
export function assertMapEqual<K extends string | number | symbol, V>(actual: Record<K, V>, expected: Record<K, V>, message: string) {
    const actualKeys = Object.keys(actual) as K[];
    const expectedKeys = Object.keys(expected) as K[];

    assert(actualKeys.length === expectedKeys.length, message);
    for (const actualKey of actualKeys) {
        assert(expected[actualKey] && actual[actualKey] == expected[actualKey], message);
    }
}

/**
 * assert两个列举的值相等,如元素相等,但是顺序不同也被视为相同
 */
export function assertArraySame<T>(actual: Array<T>, expected: Array<T>, message: string) {
    assert(actual.length === expected.length, message);
    assertSetEqual(new Set(actual), new Set(expected), message);
}

针对上述函数的测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import assert from "assert";
import hope, { assertArraySame, assertMapEqual, assertSetEqual, assertThrows } from "./hope";

hope.test('test assertSetEqual happy path', () => {
  const setA = new Set([1, 2, 3, 4, 5]);
  const setB = new Set([5, 1, 2, 4, 3]);
  assertSetEqual(setA, setB, 'Set supposed to be equal');

  assertSetEqual(new Set([]), new Set([]), 'Empty Set');
});

hope.test('test assertMapEqual unhappy path', () => {
  assertThrows(assert.AssertionError, () => {
    const setA = new Set([1, 2, 3, 4, 5]);
    const setB = new Set([1, 2, 4, 3]);
    assertSetEqual(setA, setB, 'Set supposed to be equal');
  })
});

hope.test('test assertMapEqual happy path', () => {
  const mapA = {
    'a': 1,
    'b': 2,
  };
  const mapB = {
    'b': 2,
    'a': 1
  };
  assertMapEqual(mapA, mapB, 'Map supposed to be map');
});

hope.test('test assertMapEqual unhappy path', () => {
  const mapA = {
    'a': 1,
    'b': 3
  };
  const mapB = {
    'b': 2,
    'a': 1
  };
  assertThrows(assert.AssertionError, () => {
    assertMapEqual(mapA, mapB, 'Map supposed to be map');
  });
});


hope.test('test assertArraySame happy path', () => {
  const arr1 = [1, 2, 3, 2];
  const arr2 = [2, 1, 2, 3];
  assertArraySame(arr1, arr2, "Arrays should have same elements"); // Passe
});

hope.test('test assertArraySame unhappy path', () => {
  const arr1 = [1, 2, 3, 2];
  const arr2 = [2, 1, 2, 4];

  assertThrows(assert.AssertionError, () => {
    assertArraySame(arr1, arr2, "Arrays should have same elements"); // Passe
  });
});

3.4.3 增加 -s/–select 参数指定测试文件

我们的 Runner 默认匹配的是以 test 为前缀的测试文件, 我们可以增加一个 -s/--select 参数,用来指定需要匹配的测试文件名:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const parse = (args: string[]) => {
  const parsed = minimist(args)

  return {
    ...
    select: parsed.select || parsed.s // 增加select 参数
  }
}

const main = async (args: Array<string>) => {
  const options = parse(args);
  if (options.filenames.length == 0) {
    const namePattern = options.select ?? 'test*'; // 使用传入的模式
    options.filenames = await glob(`${options.root}/**/${namePattern}.{ts,js}`);
  }

  ...
}

运行结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
> ls -al test*
-rw-r--r--@ 1 ramsayleung  wheel   115 17 Feb 10:01 test_add.ts
-rw-r--r--@ 1 ramsayleung  wheel   762 17 Feb 10:01 test_approx_equal.ts
-rw-r--r--@ 1 ramsayleung  wheel  1536 17 Feb 10:38 test_assert.ts
-rw-r--r--@ 1 ramsayleung  wheel   187 17 Feb 10:38 test_async.ts
-rw-r--r--@ 1 ramsayleung  wheel   275 17 Feb 10:38 test_setup_teardown.ts
-rw-r--r--@ 1 ramsayleung  wheel   140 17 Feb 10:38 test_tag.ts

> npx tsx pray.ts -s "test_a*"
passes:
 file:///private/tmp/reinvent/unit_test/test_async.ts::delayed test, execution time: 412us
 file:///private/tmp/reinvent/unit_test/test_assert.ts::test assertSetEqual happy path, execution time: 31us
 file:///private/tmp/reinvent/unit_test/test_assert.ts::test assertMapEqual unhappy path, execution time: 1175us
 file:///private/tmp/reinvent/unit_test/test_assert.ts::test assertMapEqual happy path, execution time: 32us
 file:///private/tmp/reinvent/unit_test/test_assert.ts::test assertMapEqual unhappy path, execution time: 85us
 file:///private/tmp/reinvent/unit_test/test_assert.ts::test assertArraySame happy path, execution time: 17us
 file:///private/tmp/reinvent/unit_test/test_assert.ts::test assertArraySame unhappy path, execution time: 54us
 file:///private/tmp/reinvent/unit_test/test_approx_equal.ts::Default margin throws exception, execution time: 111us
 file:///private/tmp/reinvent/unit_test/test_approx_equal.ts::Large margin not throws exception, execution time: 6us
 file:///private/tmp/reinvent/unit_test/test_approx_equal.ts::Relative error throw exception, execution time: 51us
 file:///private/tmp/reinvent/unit_test/test_approx_equal.ts::Default Relative error not throw exception: , execution time: 5us
 file:///private/tmp/reinvent/unit_test/test_add.ts::Sum of 1 and 2, execution time: 4us
fails:
errors:

3.4.4 增加 -t/–tag 参数按标签运行测试case

对于 hope.test 函数,我们还可以提供一个额外的参数,用于给这个test case 打标签:

1
2
3
hope.test('Difference of 1 and 2',
          () => assert((1 - 2) === -1),
          ['math', 'fast'])

然后通过 -t/--tag 按指定的tag来运行测试用例, 实现起来很容易:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
test(comment: string, callback: () => void, tags: Array<string> = []) {
    this.todo.push([`${caller()}::${comment}`, callback, tags]);
}

run(tag: string = '') {
    this.todo
        .filter(([comment, test, tags]) => {
            if (tag.length === 0) { return true; }
            return tags.indexOf(tag) > - 1;
        })
        .forEach(([comment, test, tags]) => {
            // run the test, nothing change
        })
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const parse = (args: string[]) => {
    const parsed = minimist(args)

    return {
        ...
            tag: parsed.tag || parsed.t
    }

    const main = async (args: Array<string>) => {
        ...
            hope.run(options.tag);
        ...
    }

test_tag.ts:

1
2
3
import assert from "assert";
import hope from "./hope";
hope.test('Differene of 1 and 2', () => assert((1 - 2) === -1), ['math', 'fast']);
1
2
3
4
5
> npx tsx pray.ts -t "math"
passes:
 file:///private/tmp/reinvent/unit_test/test_tag.ts::Differene of 1 and 2, execution time: 5us
fails:
errors:

3.4.5 setup与teardown

正常的测试框架都是有 setupteardown 函数的,可以指定在每个测试case 运行之前或之后的函数,比如运行测试case 前的数据准备,以为运行结束时的数据清理,我们的测试框架也可以支持这个功能:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
type CallbackType = () => void;
class Hope {
  ...
  private setupFn: CallbackType | null = null;
  private teardownFn: CallbackType | null = null;

  setup(setupFn: CallbackType) {
    this.setupFn = setupFn;
  }

  teardown(teardownFn: CallbackType) {
    this.teardownFn = teardownFn;
  }

  run(tag: string = '') {
    this.todo
      .filter(([comment, test, tags]) => {
        if (tag.length === 0) { return true; }
        return tags.indexOf(tag) > - 1;
      })
      .forEach(([comment, test, tags]) => {
        try {
          if (this.setupFn) {
            this.setupFn();
          }

          const now = microtime.now();
          test();
          const elapsedInMicro = microtime.now() - now;
          this.passes.push(comment + `, execution time: ${elapsedInMicro}us`);

          if (this.teardownFn) {
            this.teardownFn();
          }
        } catch (e) {
          if (e instanceof assert.AssertionError) {
            this.fails.push(comment);
          } else {
            this.errors.push(comment);
          }
        }
      })
  }
}

针对上述函数的测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import hope, { assertEqual } from "./hope";

let x = 0;

const createFixtures = () => {
  x = 1;
}

hope.setup(createFixtures);
hope.test('Validate x should be 1', () => {
  assertEqual(x, 1, 'X should be 1');
});

const cleanUp = () => {
  x = 0;
}

hope.teardown(cleanUp);

3.4.6 增加对 async 测试case 的支持

目前我们的test case 都只支持同步的函数, 我们可以增加上对 Promise 的支持, 这样我们可以使用以下的语法:

1
hope.test('delayed test', async () => {...})

实现方式也很直接: 一种就是判断传入函数的类型, 如果是同步函数则直接调用,如果是 async 函数, 那么就加上 await:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
type SyncCallbackType = () => void;
type AsyncCallbackType = () => Promise<void>;
type CallbackType = SyncCallbackType | AsyncCallbackType;

class Hope {
    private todo: [string, CallbackType, Array<string>][] = [];
    private setupFn: CallbackType | null = null;
    private teardownFn: CallbackType | null = null;

    setup(setupFn: CallbackType) {
        this.setupFn = setupFn;
    }

    teardown(teardownFn: CallbackType) {
        this.teardownFn = teardownFn;
    }

    test(comment: string, callback: () => void, tags: Array<string> = []) {
        this.todo.push([`${caller()}::${comment}`, callback, tags]);
    }

    private async runTest(comment: string, test: CallbackType, tags: string[]) {
        try {
            if (this.setupFn) {
                if (this.isAsync(this.setupFn)) {
                    await this.setupFn();
                } else {
                    this.setupFn();
                }
            }

            const now = process.hrtime.bigint()
            if (this.isAsync(test)) {
                await test();
            } else {
                test();
            }

            const elapsedInMicro = (process.hrtime.bigint() - now) / (BigInt(1000));
            this.passes.push(comment + `, execution time: ${elapsedInMicro}us`);

            if (this.teardownFn) {
                if (this.isAsync(this.teardownFn)) {
                    await this.teardownFn();
                } else {
                    this.teardownFn();
                }
            }
        } catch (e) {
            if (e instanceof assert.AssertionError) {
                this.fails.push(comment);
            } else {
                this.errors.push(comment);
            }
        }
    }

    async run(tag: string = '') {
        const tests = this.todo
              .filter(([comment, test, tags]) => {
                  if (tag.length === 0) { return true; }
                  return tags.indexOf(tag) > - 1;
              });


        for (const [comment, test, tags] of tests) {
            await this.runTest(comment, test, tags);
        }
    }

    private isAsync(fn: CallbackType): fn is AsyncCallbackType {
        return fn.constructor.name === 'AsyncFunction';
    }
}

pray.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const main = async (args: Array<string>) => {
  const options = parse(args);
  if (options.filenames.length == 0) {
    const namePattern = options.select ?? 'test*';
    options.filenames = await glob(`${options.root}/**/${namePattern}.{ts,js}`);
  }

  for (const f of options.filenames) {
    const absolutePath = fileURLToPath(new URL(f, import.meta.url));
    await import(absolutePath);
  }

  await hope.run(options.tag); // 增加上await
  const result = (options.output === 'terse') ? hope.terse() : hope.verbose();
  console.log(result);
}await hope.run(options.tag);

4 参考

回到本系列的目录

ReInvent: 重新造轮子系列(序言)

2025-02-17 14:10:00

1 起因与动机

最近在看 System Design By Example 这本书,主旨是通过设计和实现各种日常开发中常见的软件,以理解和提高系统设计(System Design)的能力。

每个章节都会实现一个软件,然后还会有大量的习题来完善这个软件,以练带学。

而我最推崇,并且认为最好的学习方法就是:费曼学习法(Feynman Technique), 其核心理念就是:

学习一种新事物最好的方法是,用你的话讲给别人听。

通过向别人清楚的解说某一事物,来确认自己是否真的弄懂了这件事。

所以说,学习最好的方式,是把你学到的东西教给别人。

因此,这个项目就是我在学习和理解 System Design By Example 这本书后,结合参考的论文和个人经验内化出来的学习成果。

也希望其他人也可以从中受益。

2 项目

GitHub: https://github.com/ramsayleung/reinvent

原书是用 node + Javascript 编写的,部分代码因API变更而变得不可用,因此本项目也做了对应修改,并将 Javascript 替换成 Typescript, 通过类型系统来降低维护成本。

  1. 单元测试框架
  2. 文件备份
  3. HTML Selector

杂谈AI取代程序员

2025-02-15 13:15:00

1 前言

最近国产的DeepSeek 模型的爆火,让「AI取代程序员」的说法又甚嚣尘上, 比如在国内著名的程序员社区 V2ex 搜索「AI取代程序员1,可以搜索出 69627 条结果(截止2025-02-15):

以至于我的一位非程序员朋友都来关心我,问我是否有被取代的担忧。

在感谢朋友的关心之后,我也开始静下心来思考这个问题,以及形成这种想法背后可能的原因,然后就有了这篇文章。

2 AI取代程序员

在谈论AI能否真的取代程序员之前,我想先聊一下程序员的工作是什么, 毕竟定义好了问题才会分析.

2.1 研发流程

既然是程序员,那工作肯定是写代码嘛, 但是就我个人经验来说,我真正花费在写代码的时间可能连我工作时间30%都占不到.

一个新功能大体的研发流程如下: 当产品经理或者老板提出一个新需求的时候,程序员需要评估需求的可行性,然后撰写设计文档,评审设计方案,而后编码,测试再灰度上线功能,最后对新功能增加新监控告警,提供各项数据指标。

由此可知, 产品经理只会跟你说他/她想要什么(what), 但是产品经理肯定不知道如何实现这个功能的(how), 需要程序员把模糊又朦胧的问题具体化和细致化,然后再根据方案来编码。

遇到不合理的需求时,还需要告知产品经理,你的想法是不切实际的,比如经典的「根据手机壳颜色切换壁纸」需求

但是我们在使用AI的时候,更多是一个具体而明确的任务,并且是我们针对的需求已经拆分好的任务, 所以AI在做的只是将具体的设计方案翻译成代码的步骤。

这不禁让我想起自己当年在微信支付7级晋升8级的答辩项目:一个研发提效的代码生成框架。

我当时可谓信心满满,我的项目可以根据模板直接生成代码,不再需要手写CRUD的代码,以此来给工程师提效。

评委就询问我:

你有对项目开发的各个环节的耗时和阻塞时间做量化分析么?

编码是整个研发流程效能最低的部分么?

从拿到需求到开发上线,只有编码可以提效么?

评委这么一顿批头盖脸的输出,直接把我的信心给浇没了。

虽然我写的代码生成工具远不如大模型加持的AI强大,但看起来都只局限于解决编码问题,研发流程的其他问题并没有解决。

2.2 冰山之下

冰山运动之所以雄伟壮观,是因为它只有八分之一在水面上

《午后之死》 海明威

如同只有只有八分之一在水面上的冰山一样, 很多程序员都可能没有意识到,写代码只是工作的一小部分,甚至可能是其中最轻松的部分, 维护才是工作中最繁琐且具有挑战性的部分。

维护工作包含测试,debug, 修bug, 性能优化, 更新以适应其它改变,代码重构,客户支持,写文档并随时间修改文档.

以我自己的经历为例,之前组里面有不少近10年的历史服务,但是还在发光发热,作为核心组件给客户提供服务.

这些服务基本都是用Java写的, Java 以「Write once, run everywhere」且优秀的向后兼容能力而闻名, 因此还有不少公司还在使用JDK8, 仿佛被封印在 Java8 一样了。

不久前, 在公司内部推行了一个要求将JDK升级到JDK17的全公司范围的项目:

而升级JDK的动机,一方面是因为可以使用最新的语言特性,此外JDK8也马上要被停止支持了,另外一方面是性能优化, 各种评测都显示,从JDK8升级到JDK17,升级后JVM的系统资源使用量平均下降约10%~15%, 对于公司而言,更少的系统资源,就可以使用更少的服务器,通过升级JDK 就可实现无痛「降本增效」。

但即使兼容能力优秀如Java,我也花费了近3周才把组里使用 JDK8/JDK11 的老服务切换到 JDK17,

期间还出现了各种奇怪的问题,比如JVM参数名或者选项在升级之后被废弃了, 新版本JVM 无法识别,又或者是出于安全的考虑,部分JDK内部的 class 无法通过反射访问,会导致运行时异常,服务需要回滚.

AI 也没法帮我们一键升级或维护, 而这些枯燥且繁琐的工作才是那未被人所见的「八分之七」.

2.3 功能性与非功能性需求

在程序开发中,除了有功能性需求,还需要非功能性需求.

而功能性需求恰恰是其中最简单直接的,是满足「又不是不能用」的最基本要求。

比如我需要实现加法,这个就是功能性需求,AI就需要帮我把加法实现出来。

而所谓的非功能性需求,比较常见的是可扩展性(scalability),可维护性(maintainability) 和性能(performance).

2.3.1 非功能性需求

还是以加法为例, 我可能需要后面扩展到乘法和除法, 或者扩展到复数或者矩阵加法,要怎么易于扩展呢?

这些都是AI生成代码是没有考虑到的因素,AI比较擅长的可能是给它一个需求,它生成一段代码给我们。

但是在软件研发的生命周期中,有大概80%的时间,都是维护已有的系统。

而给已有的系统上增加功能,就需要考虑各种奇怪的兼容性,相当于带着锁链来跳舞,并不能像从无到有,什么都不用考虑的那般洒脱。

而这样的限制,又是AI生成代码时未曾考虑的。

所以从零开始的日抛型,不需要考虑维护成本的项目,很适合由AI来生成.

2.3.2 已解问题

但即使功能性的需求,有时候AI也会做不好。

我之前在工作中需要根据已有的Schema, 使用Rust 写入Parquet数据,Rust本来就新,加之操作Parquet的库就更少了, 所以ChatGPT, Claude或者Gemini 都没有给出我满意能跑的结果,最后还是靠自己去读 Parquet库的源码找出的解决办法。

无论模型怎么变,AI现阶段还需要预训练数据的投喂,所以没有相关的预处理数据,AI也只能胡扯。

换而言之,所限于当前的AI模型,AI是只 善于处理已经被解决过的问题

所以总结下来,在真实项目中,AI还没有办法取代程序员。

2.4 AI取代论背后的动机

既然你说AI现阶段还没有办法取代程序员,为什么我看到各种各样程序员要被AI取代的新闻,甚至有公司用AI来替代员工了?

除去真的担心自己被AI取代的从业者外,我觉得看到两种扯着AI大旗的人。

一种就是懂程序开发,装不懂程序开发,然后贩卖焦虑的人,最近非常火的就是,「我用Cursor从0 xx 天开发了一个App」 (Cursor是最近一个很火的AI编辑器,可以通过输入的指令生成代码).

大体都是说自己完全不懂程序开发,借助AI/Cursor, xx天内开发出了一个App, 并且冲到了苹果App Store热门榜第一.

普通程序员看到这种说法,一个小白从无到有都能开发出一个App, 难免会焦虑,不得不说,这种反向装弱再变强真的令人防不胜防, 想着不能被落下,就斥资加入各种付费群或者购买各种课程。

神智清明的你可能在心平气和下很难做出这样的决定嘛。

制造焦虑才能更好地贩卖自己的产品,毕竟到现在为止,靠AI盈利主要有两种方式:

  1. AI 公司靠产品融资,拉风投
  2. 制造AI焦虑,借机卖课卖书卖文档

另外一种就是要用AI来取代程序的公司,说要通过AI来增效,裁撤工程师,比如国外的 Workday 裁员说要再招人做 AI 的新闻 2, 还有2023年 Google 因为AI裁员而30000 名员工的新闻 3

怎么说呢,AI这块牌子太好用了, 什么都可以往里面套,裁撤工程师降本是真,AI增效大概是假。

裁员就裁员呗,为什么还要说是用AI来取代程序员呢?

因为说是AI取代了程序员,被毕业的员工们和待毕业的员工们都只会把裁员的原因归罪到AI上, 管理层的无所作为,墨守成规,决策失误,无序扩张这些失误都不需要承诺后果了,反正背锅有AI。

苦一苦员工,骂名AI来担。

3 AI一无用处

当然凡事都有例外,有人觉得AI会取代程序员,也有人觉得AI一无用处,不值一提。

我对此也是持反对态度了,虽然AI无法取代程序员,但是它的确在很多方面可以给我提升相当大的效率,其效率跃升堪比计算机连上互联网。

所以我也藉此来分享下我的AI用法

4 我的AI用法

我使用对AI的定位把它当作一位非常耐心,能力全面的助理,所以我给它分配的都是明确而繁琐的任务,并且我有能力对其输出进行评判的情景。 (也就是让它干脏活累活)

先声明一下我使用的AI 模型,日常事务我会使用ChatGPT, 响应速度快,量大够饱; 编码我会使用Claude 3.5 Sonnet,目前是我使用过效果最好的; 中文相关会使用DeepSeek, 但是经常服务器繁忙,体验不好。

4.1 生成测试用例

测试用例太适合让AI来写了,毕竟任务是非常明确的,需要被测的函数也是非常明确的,你只需要使用把测试的场景说清楚。

我觉得都可以把这个内容扩展写成 测试技能进阶系列 的第四篇了

假设我需要测试一个计算字符串中元音数量的函数 count_vowels(s: str) -> int, 我的指令是:

请为以下函数 `count_vowels(s: str) -> int` 生成pytest测试用例:

  1. 正常输入包含大小写元音的情况
  2. 空字符串边界条件
  3. 全元音字符的特殊情况
  4. 不含元音的字符串
  5. 非法输入类型校验

AI生成结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import pytest

def test_count_vowels_normal_cases():
    # 混合大小写元音测试
    assert count_vowels("Hello World") == 3
    assert count_vowels("AEiou") == 5

def test_count_vowels_edge_cases():
    # 空字符串/全元音测试
    assert count_vowels("") == 0
    assert count_vowels("aei") == 3

def test_count_vowels_special_cases():
    # 无元音字符串测试
    assert count_vowels("rhythm") == 0
    assert count_vowels("12345!@#") == 0

def test_count_vowels_invalid_input():
    # 类型校验测试
    with pytest.raises(TypeError):
        count_vowels(123)
    with pytest.raises(TypeError):
        count_vowels(["hello"])

4.2 语言学习

AI的多语言翻译功能对比其他传统基于单词的翻译软件简直是降维打击bb,毕竟Google的科学家最初做 Transform 模型就是为了做翻译的。

记得我之前考雅思的时候,口语和写作考前复习就是把题库的复制给AI,然后让他来给我答题,我就把思路背下来,做题这方面,AI实在是太擅长了。

Given the following IELTS Speaking Part1, you are an examinee, could you give me some band 9 answer:

Ielts Speaking 3 Practice 5 (Change)

  1. Do you think change is good?
  2. What are some of the major changes that occur to people throughout their lives?
  3. Is your country changing rapidly?
  4. In what ways have changes in technology changed people’s lives?
  5. Why do old people not accept change?

Here are some Band 9 answers for the IELTS Speaking Part 1 topic “Change”, with a natural, fluent style and advanced vocabulary.

  1. Do you think change is good?

Absolutely. Change is an inevitable part of life, and in most cases, it drives progress and personal growth. While change can be daunting at first, it often leads to new opportunities and valuable experiences. Without change, we would stagnate, both individually and as a society. …

让AI做词典, 帮我解释和翻译我不懂的单词,或者做中译英方便交流:

You are a professional translator. Translate all input Chinese paragraphs into English and all input English into Chinese, and help me explain its meaning

而后我输入中文,它就会给我英文翻译;输入英文,它会给我中文翻译:

“爱哭的孩子有奶喝”


“The crying baby gets the milk.” Meaning:

This Chinese proverb “爱哭的孩子有奶喝” (ài kū de háizi yǒu nǎi hē) means that those who complain, express their needs, or fight for their interests are more likely to get attention and benefits. It suggests that speaking up and advocating for oneself can lead to better outcomes, similar to how a baby who cries gets fed first.

illiterate


“Illiterate” 的中文翻译是 “文盲” 或 “不识字”。 含义:

  1. 基本含义:指一个人 不识字或无法阅读和书写。

    • 例如:Many people in the past were illiterate because they had no access to education.(过去,许多人因为无法接受教育而是文盲。)
  2. 引申含义:在某个领域缺乏基本知识或技能。

    • 例如:“He is financially illiterate."(他在财务方面一窍不通。)
    • 在科技、法律、文化等方面,也可以用 illiterate 来形容缺乏基础知识的人,例如 “technologically illiterate”(科技文盲)。

还有一个就是让AI 给我写的内容纠错,这个也比传统的语法工具类如 Grammarly 好用.

You are a professional and seasoned translator, correct me if I am wrong.

4.3 代码审计

有时候我会查看一些智能合约的代码或者TG bot的代码,在实际交互前,我一般会把代码丢给AI,让它检查一下是否有可疑的代码片段,类如窃取用户的私钥,把资金转到可疑账户等等。

或者把我自己写的代码丢给AI,让它看下是否有安全漏洞:

Given the following code, help me spot if there is any vulnerability here:

1
2
3
4
5
@app.route('/search')
def search():
    query = request.args.get('q')
    cursor = db.execute(f"SELECT * FROM products WHERE name LIKE '%{query}%'")
    return render_template('results.html', data=cursor.fetchall())

Yes, there are several serious security vulnerabilities in this code. Let me break them down:

  • SQL Injection Vulnerability (Critical):

The most severe issue is that the code uses string formatting (f-string) to directly insert user input into the SQL query. This allows SQL injection attacks. For example, a malicious user could input:

Here’s a secure version of the code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@app.route('/search')
def search():
    query = request.args.get('q')
    if query is None:
        return render_template('results.html', data=[])

    # Using parameterized query with placeholders
    cursor = db.execute(
        "SELECT * FROM products WHERE name LIKE ?",
        ('%' + query + '%',)
    )
    return render_template('results.html', data=cursor.fetchall())

4.4 反编译

现代前端基本都会使用类似webpack 的打包工具进行打包压缩, 甚至还会进行代码混淆,所以F12 查看源码,并不能查出端倪.

因此我一般都是让AI 来帮我反编译和还原, 这样就免去了复杂的人工转译的过程:

Given the obfuscated and compressed code, could you reverse it back to the original source code?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
var _0x3f2a = ['\x48\x65\x6c\x6c\x6f', '\x6c\x6f\x67'];
(function(_0x12d3f0, _0x3f2a8c) {
    var _0x553e8f = function(_0x5e7a28) {
        while (--_0x5e7a28) {
            _0x12d3f0['push'](_0x12d3f0['shift']());
        }
    };
    _0x553e8f(++_0x3f2a8c);
}(_0x3f2a, 0x1a4));
var _0x553e = function(_0x12d3f0, _0x3f2a8c) {
    _0x12d3f0 = _0x12d3f0 - 0x0;
    var _0x553e8f = _0x3f2a[_0x12d3f0];
    return _0x553e8f;
};
function _ax342() {
    var _0x5e7a28 = _0x553e('0x0');
    console[_0x553e('0x1')](_0x5e7a28 + ' World!');
}
_ax342();

Let me help you deobfuscate this code. Based on the patterns and structure, this appears to be JavaScript code that has been obfuscated using common techniques like array-based string storage and variable name mangling. The original source code would be:

1
2
3
4
function greet() {
    console.log("Hello World!");
}
greet();

着实有点东西

4.5 排查问题

在出现大模型AI之前,当遇到某个工具或者库的错误信息时,我一般的做法是:

  1. 复制错误信息
  2. 粘贴到Google
  3. 点击排名最靠前的Stackoverflow 链接
  4. 扫一下是否是我想问的问题,如果不是就再回到2
  5. 按照最高赞的解决方案尝试下
  6. 如果还不行,再回到2

有了AI之后,我一般的做法是:

  1. 复制错误信息
  2. 问AI “How to fix this erro”, 粘贴上错误信息
  3. 按照AI的建议一步步尝试
  4. 如果不行,把最新的错误信息复制粘贴给AI, 告诉它这不管用.

与Google 的方案相比,我现在有了个再反馈的过程, 可以把最新的结果再告知AI, 这样更方便AI基于上下文做出解答.

4.6 数据处理

非结构化数据转换成结构化数据,如JSON, 相当于让AI做了数据清洗的脏活:

Given the following output of ls command, could you convert it to JSON?

1
2
3
4
5
6
7
8
9

-> % ls -alrt
total 828
-rw-rw----+   1 ramsay ramsay   4672 Mar 21  2019 abbrev_defs
-rw-rw----+   1 ramsay ramsay  10079 Mar 21  2019 recentf~
drwxr-xr-x    2 ramsay ramsay   4096 Apr 16  2021 images
drwxrw----+  44 ramsay ramsay   4096 Apr 16  2021 snippets
-rw-rw----+   1 ramsay ramsay   3740 Dec 10  2021 projectile-bookmarks.eld
-rw-rw----+   1 ramsay ramsay 241501 May 27  2022 company-statistics-cache.el

Here is the JSON representation of the given ls -alrt output: Explanation:

  • Each file/directory is represented as an object inside the “files” array.
  • “name” is the file/directory name.
  • “permissions” is the file permissions.
  • “links” is the number of hard links.
  • “owner” is the owner of the file.
  • “group” is the group the file belongs to.
  • “size” is the size of the file in bytes.
  • “modified” is the date of the last modification.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
     {
  "files": [
    {
      "name": "abbrev_defs",
      "permissions": "-rw-rw----+",
      "links": 1,
      "owner": "ramsay",
      "group": "ramsay",
      "size": 4672,
      "modified": "Mar 21 2019"
    },
    {
      "name": "recentf~",
      "permissions": "-rw-rw----+",
      "links": 1,
      "owner": "ramsay",
      "group": "ramsay",
      "size": 10079,
      "modified": "Mar 21 2019"
    },
    {
      "name": "images",
      "permissions": "drwxr-xr-x",
      "links": 2,
      "owner": "ramsay",
      "group": "ramsay",
      "size": 4096,
      "modified": "Apr 16 2021"
    },
    {
      "name": "snippets",
      "permissions": "drwxrw----+",
      "links": 44,
      "owner": "ramsay",
      "group": "ramsay",
      "size": 4096,
      "modified": "Apr 16 2021"
    },
    {
      "name": "projectile-bookmarks.eld",
      "permissions": "-rw-rw----+",
      "links": 1,
      "owner": "ramsay",
      "group": "ramsay",
      "size": 3740,
      "modified": "Dec 10 2021"
    },
    {
      "name": "company-statistics-cache.el",
      "permissions": "-rw-rw----+",
      "links": 1,
      "owner": "ramsay",
      "group": "ramsay",
      "size": 241501,
      "modified": "May 27 2022"
    }
  ]
}

对于非结构化的数据,或者缺失格式的数据, AI都能处理.

5 总结

这是我用即梦AI生成的一张街舞海报,我注明了地址,联系电话,以及价格,生成出来的结果是这样的

远看是那么一回事!细看是什么回事?

AI现阶段还是无法取代程序员的,未来AI我也觉得很难取代程序员(当然,取代把需求翻译成代码的人肉翻译器还是有可能的),因为程序员虽然是和机器打交道,但是本质上还是解决人的问题。

担忧程序员被AI取代的人可能只看到了测试场景里面的 happy path, 也就是AI能生成老板们想要的代码,没有看到unhappy path, 也就是老板们想要的代码, AI生成不出来要怎么办?

毕竟人可以根据已有的经验进行归纳总结,把已有的经验应用在未踏足的领域,且持续学习,但是AI还是受限于预训练数据,做不出来就是做不出来。

但是对程序员而言,AI不失为一个好用且提效的工具,好用的工具就应该多学多用多玩,而不是担忧被其取代。

AI无法取代持续精进者, 傲慢自满方为淘汰之源

(什么,你问我当初的答辩结果?自然是


挂了)

推荐阅读

qrcode_gh_e06d750e626f_1.jpg 公号同步更新,欢迎关注👻