把“区块链”从概念翻译成C++代码,这件事我今年才算真正做完。之前看了大量资料,翻来覆去都在强调“去中心化”“不可篡改”,概念背得滚瓜烂熟,但真要我动手写一个能跑的区块链,脑子里一片空白。后来我从零开始,用C++实现了一条最小可用的区块链:有区块、有哈希、有工作量证明、有链校验,命令行跑起来能看到每个块的哈希,篡改交易之后校验立刻失败。这个项目不依赖任何区块链框架,也不需要现成的节点程序,纯靠C++标准库加一个很小的SHA-256工具就能跑起来,非常适合想搞清楚区块链底层原理、又对C++比较感兴趣的人。
这篇文章我打算把整个项目从设计到实现每个环节都拆开讲,包括为什么用C++、区块和链的数据结构怎么设计、挖矿到底在代码里做了什么、校验函数怎么写,以及我实际踩过的几个坑。如果你是C++新手,跟着敲一遍也能跑通;如果你已经有C++基础,那可以直接跳到后面的实现细节和避坑部分,收获会更大。
1. 这个项目到底在做什么:定位与选型
很多人一听到“区块链项目”,第一反应是比特币或者以太坊那样庞大的系统,觉得一个人不可能写出来。确实,生产级区块链牵扯到P2P网络、共识协议、密码学签名、状态存储、Merkle树等等,复杂度非常夸张。但我们的目标完全不同:只做一个教学原型,把“区块链”这个概念用最朴素的C++代码表达出来,能跑、能验证、能看清内部机制,这就够了。
1.1 为什么是C++
写教学区块链,最简单的选择其实是Python,代码又短又直观。但我觉得C++版本的价值大得多,主要原因有几个:
- 真实世界的区块链项目和C++关系非常紧密。比特币核心客户端Bitcoin Core就是用C++写的,很多高性能区块链项目也偏爱C++。用C++写一遍,读那些开源源码时熟悉感会强很多。
- C++能逼着你把类型、内存、传参方式、作用域这些基础问题想清楚。比如区块的哈希是std::string,上一区块的引用是字符串而不是指针,nonce是整型,这些选择背后都有原因,写完一遍印象极深。
- 在这个小项目里能同时练到C++的类设计、STL容器、const成员函数、引用传递、时间戳处理这些非常高频的语法点,属于“一个项目复习半本C++教材”。
不过要提醒一下,这个项目虽然代码量不大,但如果C++语法基础比较薄弱,建议先把类和STL基础的用法过一遍再来,否则在candidate()函数的字符串拼接上容易卡很久。
1.2 项目目标与最终效果
我把项目目标限定在四个核心能力上:
- 定义区块和链的数据结构,链是一串区块按顺序引用组起来的。
- 用SHA-256计算每个区块的哈希,形成区块“指纹”。
- 实现工作量证明,也就是所谓的“挖矿”:不断更换nonce,直到算出来的哈希满足难度条件。
- 实现链校验函数,从头到尾检查每个区块引用是否连续、哈希是否一致、是否满足难度要求。
最终跑起来的效果,大概是:
- 程序自动创建创世区块,难度设为4,也就是哈希必须以前缀“0000”开头。
- 然后连续添加几个区块,每个区块包含一笔简单的交易记录(比如“A转给B 10”)。
- 控制台打印出每个区块的编号、时间戳、交易内容、前序哈希和当前哈希。
- 在内存里手动篡改某个区块的交易字符串,再调用链校验函数,返回0(无效),说明篡改被检测出来了。
1.3 涉及的知识点梳理
这个项目虽然“小”,但用到的知识点一点也不少:
- C++层面:std::string的拼接和比较、std::vector存储动态数组、std::to_string做整型到字符串的转换、time_t和std::time获取时间戳、类成员变量的设计、函数重载和const成员函数、引用传递避免拷贝。
- 区块链层面:创世区块、哈希链、内容寻址(前序引用的是哈希字符串而不是索引)、工作量证明、难度值、链校验规则。
这些知识串起来之后,你会对“区块链技术”有一个非常具体的代码级认知:区块链不是一个文件,也不是一个数据库,它本质上就是一个由哈希引用串起来的数据结构,外加一套让所有人愿意追加新区块的校验规则。
2. 核心数据结构与原理:把“区块链”翻译成C++代码
在动手写代码之前,最值得花时间的不是急着敲键盘,而是先把“一个区块里到底放什么”想清楚。这个部分决定了后面所有代码的走向,也最容易出现从头返工的情况。
2.1 区块的数据结构设计
我的区块类成员变量设计如下:
- index:区块编号,也就是区块高度。创世区块是0,之后依次递增。
- timestamp:时间戳,记录这个区块生成的时间。
- transactions:交易列表。真实区块链中的区块存的是交易集合,教学实现里我用std::vector std::string 存若干条交易记录的字符串。
- prev_hash:前一个区块的哈希值。这是区块链最核心的“链条”所在,正是通过这个字段把区块一个个串起来。
- hash:当前区块的哈希值。注意这个字段在创建区块时是不知道的,必须通过哈希计算得到。
- nonce:工作量证明中不断尝试的“幸运数字”,挖矿的过程就是不断修改nonce直到哈希满足条件。
- difficulty:当前链的难度值,比如4表示哈希前4个字符必须是0。
有个设计细节值得说一下:prev_hash我用的是std::string存哈希,而不是用一个指针指向前一个区块。这背后有一个很重要的理念——区块链是“内容寻址”的,每个区块的身份由它的哈希决定,后一个区块指向的是前一个区块的哈希值,而不是它在内存里的地址。哈希一旦变了,所有后面引用它的区块都会失效。如果用指针,那就退化成普通链表了,完全体现不出区块链的核心特性。
生活里可以这样类比:每个区块就像一页手工账本。每页的页眉上不只写了这页的内容,还盖着上一页的印章编号;只要有人偷偷改掉某一页的某个数字,它自己的印章变了,后面每一页的“上一页印章”也就对不上了,整本账一眼就能发现被动过手脚。
2.2 哈希:区块链最底层的“指纹”
哈希函数在这个项目里扮演的角色,就是给任意长度的数据生成一个固定长度的指纹。SHA-256的输出是64个十六进制字符,比如:
0000a7f2c31b2f8f8d6d6f1c9c3f8e1a5b4f2a5f6f0d3b2c7a1e2c3d4e5f6a7b8c哈希有四个关键特性:
- 无论输入多长,输出长度固定。
- 输入只要有一丁点变化,输出会面目全非,这叫雪崩效应。
- 从输出反推输入几乎不可能。
- 计算速度快,可以反复试算。
所以,哈希可以理解成一台“榨汁机”:你塞进去一整段文本,出来的永远是固定容量的一杯汁;稍微换一点原料,出来的汁颜色和味道完全不一样。区块链正是用这个“指纹”来识别数据,一旦数据有任何改动,指纹必然会变。
在这个项目里,我对“整段内容”的定义是:
index + timestamp + transactions + prev_hash + nonce这五个字段拼接成候选字符串,然后对它做SHA-256。这个拼接的格式极其重要,必须让挖矿和验证共用同一段拼接逻辑,否则哈希永远对不上。
2.3 工作量证明:从“挖矿”到代码
所谓挖矿,在真正的区块链里面就是“竞争记账权”:谁能先找到一个满足难度条件的哈希,谁就有权把新块写到链上。在我们这个教学项目里,挖矿简化成了下面这个问题:
找到一个nonce,使得:
SHA256(index + timestamp + transactions + prev_hash + nonce)得到的哈希字符串前difficulty个字符都是0。
为什么只能靠“试”?因为SHA-256的输出是均匀分布的,你没有办法预判哪个nonce能算出前导0,只能从0开始一个一个试。每试一次就是一次完整的SHA-256计算,试到满足条件为止。这里的“试错”就是工作量,哈希条件的难易程度,可以通过调整difficulty来控制。
平均尝试次数很容易估算:哈希每一位有16种可能(0到f),要让前difficulty位全为0,概率是(1/16)^difficulty,所以平均要尝试16^difficulty次。difficulty为4时,平均要试65536次;difficulty为5时,平均要试1048576次。这就很好地解释了为什么难度提升一个单位,挖矿耗时大约会变成16倍。
顺便说一句,真实比特币系统的难度不是用“前几位是0”这种直观方式表示的,而是用一个很大的目标整数,哈希值需要小于等于目标值才合法,同时系统会根据全网算力动态调整目标值,保证平均出块时间稳定在10分钟左右。教学实现里用前导0更方便观察和理解,两者本质上是同一个思想。
2.4 链式校验:“不可篡改”在代码里究竟怎么实现
实现链校验函数isChainValid时,从第1个区块开始(创世区块不用验证,因为它的prev_hash是人为指定的“0”),逐个检查三个条件:
- 当前区块的prev_hash是否等于前一个区块的hash。
- 把当前区块的候选串重新计算一遍哈希,看是否等于当前区块里保存的hash。
- 当前区块的hash是否仍然满足难度条件(前difficulty个字符为0)。
任何一个条件不满足,校验失败,返回false。
这就是区块链“不可篡改”的真正含义,并不是说数据物理上无法修改,而是说篡改后的整条链在校验逻辑面前完全站不住脚。你改了第2个区块的交易,第2个区块的hash就会变;你手动把第2个区块的hash改成新的值,第3个区块的prev_hash又对不上了。除非你从第2个区块开始,把后面所有区块全部重新挖一遍,才可能让链表面上自洽。
但即便如此,在点对点网络里,其余所有节点都保存着各自的链副本,你重新挖出来的链如果长度不超过网络中最长链,就不会被其他人接受。这就是“不可篡改”真正冷酷的地方,不是不能改,而是改了白改。
3. 完整实现:从空文件到跑通整条链
下面进入正题,把代码一层层写出来。我的实现完整放在了单个.cpp文件里,总共也就两百多行,非常适合作为初学者敲的第一份“区块链源码”。
3.1 准备工作与第三方哈希库
SHA-256算法如果完全自己实现,代码会非常长,而且容易在移位和字节序处理上出错。教学项目没必要重复造这个轮子,我选择了picosha2这个轻量级开源库。它是一个单独的头文件,不需要安装配置,把它放到项目目录下就能直接使用,非常适合教学场景。
在文件头部引入:
#include "picosha2.h"picosha2的使用方式很简单:
std::string input = "hello"; std::string hash = picosha2::hash256_hex_string(input);这样hash就是input的SHA-256十六进制字符串,全部是小写字母。整个项目只需要这一个哈希接口,非常省事。如果你不想用第三方库,也可以换成OpenSSL的SHA-256接口,但代码会多几行配置,对新手不太友好。
3.2 区块类:字段、构造函数与候选串
先定义区块类:
#include <iostream> #include <string> #include <vector> #include <ctime> #include "picosha2.h" class Block { public: int index; time_t timestamp; std::vector<std::string> transactions; std::string prev_hash; std::string hash; int nonce; int difficulty; Block(int idx, time_t ts, const std::vector<std::string>& txs, const std::string& prev, int diff) : index(idx), timestamp(ts), transactions(txs), prev_hash(prev), difficulty(diff), nonce(0) {} // 生成用于哈希计算的候选串 std::string candidate() const { std::string s = std::to_string(index) + std::to_string(timestamp) + prev_hash + std::to_string(nonce); for (const auto& t : transactions) { s += t; } return s; } // 计算当前候选串的哈希 std::string calculateHash() const { return picosha2::hash256_hex_string(candidate()); } };这里最关键的就是candidate()函数。它的作用是规定“什么样的数据参与哈希计算”,并且把这个拼接逻辑固定下来。我在项目一开始犯过一个错误:挖矿的时候手写了一版拼接,验证的时候又手写了一版拼接,两版看起来差不多,一个少拼了nonce,一个多拼了一个空格,结果区块永远校验不过。后来老老实实把拼接逻辑收敛到一个函数里,所有地方都调用它,问题立刻消失。
这个函数改成const,是因为它只读取成员变量,不修改任何东西。C++里这种习惯要养成,后面在Blockchain类里用const Block&引用遍历链时,就能顺利调用这些只读接口。
时间戳我也解释一下:std::time(nullptr)返回当前Unix时间戳,类型是time_t。直接打印会得到一个很大的整数,比如1700000000。把时间戳放进哈希候选串之后,即使其他字段相同,只要时间戳不同,算出来的哈希也完全不同。这正是哈希雪崩效应的体现。
3.3 实现工作量证明:挖矿循环
挖矿的本质是暴力搜索nonce。我实现的mineBlock函数:
class Block { // 省略前面的成员和构造函数 void mineBlock() { while (true) { hash = calculateHash(); if (isHashValid(hash, difficulty)) { break; } nonce++; } } }; bool isHashValid(const std::string& hash, int difficulty) { if (static_cast<int>(hash.size()) < difficulty) return false; for (int i = 0; i < difficulty; ++i) { if (hash[i] != '0') return false; } return true; }这里好几个细节都值得琢磨:
- 我用while(true)而不是do-while。原因是nonce的递增时机非常容易出错。如果写do-while,循环体结束后nonce已经多加了一次,退出循环时保存的nonce和实际算出合法hash的nonce不一致,后面验证也会出错。while(true)写法相对不容易出这个问题。
- 挖矿成功之后,hash成员被写入合法哈希,nonce停留在合法值,区块内部状态自洽。
- isHashValid里先检查长度,防止字符串比difficulty短的时候直接越界访问。这个防御性检查虽然在这个场景里不会触发,但属于良好的编码习惯。
你可以把measure一下,当难度为4时,我的机器上每个区块大约需要0.1到0.2秒;难度调到5就要3到5秒;难度6就要一两分钟甚至更久。第一次看到0000开头的哈希打印出来时,会特别有成就感。
3.4 区块链类:添加区块与链校验
链本身最简单,就是一个按顺序存区块的容器。我直接用std::vector ,但要注意:Block内部有std::vector std::string ,拷贝整个链时开销不小,所以在不需要修改的地方尽量用const引用,避免无谓复制。
class Blockchain { private: std::vector<Block> chain_; int difficulty_; public: Blockchain(int difficulty) : difficulty_(difficulty) { chain_.push_back(createGenesisBlock()); } Block createGenesisBlock() { std::vector<std::string> txs; txs.push_back("genesis block"); Block genesis(0, std::time(nullptr), txs, "0", difficulty_); genesis.mineBlock(); return genesis; } const Block& lastBlock() const { return chain_.back(); } void addBlock(const std::vector<std::string>& txs) { Block newBlock(static_cast<int>(chain_.size()), std::time(nullptr), txs, lastBlock().hash, difficulty_); newBlock.mineBlock(); chain_.push_back(newBlock); } bool isChainValid() const { for (size_t i = 1; i < chain_.size(); ++i) { const Block& cur = chain_[i]; const Block& prev = chain_[i - 1]; if (cur.prev_hash != prev.hash) return false; std::string recalc = picosha2::hash256_hex_string(cur.candidate()); if (recalc != cur.hash) return false; if (!isHashValid(cur.hash, difficulty_)) return false; } return true; } const std::vector<Block>& chain() const { return chain_; } };几个设计点:
- 创世区块的prev_hash被手动固定为字符串"0",这是一种惯例写法。创世区块也会执行挖矿,虽然它没有前序引用,但同样要有合法哈希,这样链的规则从头到尾一致。
- addBlock里创建新区块时,prev_hash直接取当前链最后一个区块的hash,然后用mineBlock填充新区块的hash和nonce,最后push_back。
- isChainValid从下标1开始遍历,跳过创世区块。这里的const Block&引用很重要,如果写成Block cur = chain_[i],会触发Block的拷贝构造函数,白白复制整个交易vector,性能差且容易在修改时产生困惑。
3.5 运行结果演示与篡改测试
main函数这样写:
int main() { Blockchain bc(4); std::vector<std::string> txs1; txs1.push_back("Alice -> Bob 10"); bc.addBlock(txs1); std::vector<std::string> txs2; txs2.push_back("Bob -> Carol 5"); bc.addBlock(txs2); std::cout << "Is valid: " << bc.isChainValid() << std::endl; const auto& chain = bc.chain(); for (size_t i = 0; i < chain.size(); ++i) { const Block& b = chain[i]; std::cout << "Block " << b.index << std::endl; std::cout << " timestamp : " << b.timestamp << std::endl; std::cout << " transactions: "; for (const auto& t : b.transactions) { std::cout << t << "; "; } std::cout << std::endl; std::cout << " prev_hash : " << b.prev_hash << std::endl; std::cout << " hash : " << b.hash << std::endl; std::cout << " nonce : " << b.nonce << std::endl; } // 篡改测试:修改区块1的交易内容 // 注意:因为链对象通过const引用返回,这里直接修改需要去掉const或用可写副本 // 这里用一个更清晰的演示方式:拷贝链,修改副本,再验证副本 Blockchain tampered = bc; auto& tamperedChain = const_cast<std::vector<Block>&>(tampered.chain()); tamperedChain[1].transactions[0] = "Alice -> Carol 999"; std::cout << "Tampered chain valid: " << tampered.isChainValid() << std::endl; return 0; }实际运行时,每个区块的哈希前面会有“0000”前缀,后面跟一长串十六进制字符。难度为4时,nonce会在几百到几万之间变化,完全没有规律,这正体现了哈希输出均匀随机分布的属性。
篡改测试这一段用了const_cast去掉const,目的只是演示修改后果。实际工程中不要这么干,我们只是为了让教学效果直观,让读者亲眼看到“改一行交易之后校验失败”的结果。输出最后会同时出现:
Is valid: 1 Tampered chain valid: 0这一行输出是整个项目最有价值的时刻:它把“不可篡改”这四个字变成了你亲手实验出来的事实。
4. 实操中的坑与排查记录
代码能跑通只是第一步,真正让人成长的是踩坑和排查的过程。我在这几百行代码里踩过的坑,现在回想起来都还挺典型的,值得拿出来逐个分析。
4.1 最常见翻车现场:哈希永远对不上
我第一次实现时,isChainValid一直返回false,打印出来的recalc和cur.hash怎么都不相等。排查了半天,发现是因为我在两个地方分别写了字符串拼接,一个用std::to_string(nonce),另一个不小心直接把char转成字符串拼进去;还有一次是把transactions里所有交易拼在了prev_hash前面,拼的顺序和挖矿时不一致。
这个问题的根源是“候选串格式没有被统一约束”。C++编译器不会告诉你“你拼接顺序错了”,它只会诚实地算出一个完全不同的哈希。解决方式就是我前面强调的:把拼接逻辑收敛到candidate()这一个函数里,挖矿、验证、重新计算哈希都使用同一函数,保证永远一致。这属于典型的“设计问题在运行期爆发”的例子,也让我更加理解为什么真实区块链系统的序列化规则会被当作协议标准一样严格维护。
4.2 时间戳的“共识陷阱”
因为每个区块的时间戳都是用std::time(nullptr)取的本地时间,在单机演示时完全没问题。但如果把这个教学项目扩展到多节点,问题就来了:不同机器上的系统时间可能差几秒甚至几分钟。如果直接把时间戳拼进哈希候选串,两个节点的同一笔交易打包成区块后哈希完全不同,网络同步就会出问题。更麻烦的是,恶意节点可以故意改时间戳来调整自己的挖矿难度,这在真实系统里是被严格约束的。
教学层面的思考是:时间戳是一个“有语义”的字段,要意识到它参与了哈希计算,所以修改它等于修改区块内容,一样会导致校验失败。真实的区块链系统对时间戳有范围限制,超过前后合理偏移的区块会被拒绝,目的就是为了防止节点通过伪造时间戳来操纵出块过程。
4.3 难度参数与课堂演示的平衡
我在本地测试时踩过另一个坑:把难度设成了4,跑得非常快;改成6之后,一个区块挖了将近两分钟,我还以为自己死循环了。后来才想起概率公式:平均尝试次数是16^difficulty,每加1,计算量大约增长16倍。这是个指数曲线,调参的时候千万不要凭感觉往上加。
经验参照:
| difficulty | 平均尝试次数 | 本地耗时体感 |
|---|---|---|
| 3 | 4096 | 瞬间完成 |
| 4 | 65536 | 0.1~0.2秒 |
| 5 | 1048576 | 几秒到十几秒 |
| 6 | 16777216 | 一两分钟以上 |
教学演示建议:如果只是展示快速效果,难度设3;如果想让学生感受到“挖矿耗时”的存在,难度设4到5效果最好。难度6以上不建议在课堂上用,等学生回家自己试。
4.4 常见问题速查表
把我在实操中遇到的问题整理成一张表,方便你对照排查:
| 现象 | 原因 | 排查与解决 |
|---|---|---|
| 链校验一直返回false | 挖矿时和验证时的候选串拼接不一致 | 统一使用candidate()函数,不要手写两套拼接逻辑 |
| 挖矿循环很久跑不完 | 难度设置得过高 | 教学演示把difficulty控制在4~5,不要超过6 |
| nonce打印出来特别巨大 | 哈希难度较大或运气不好 | 属于正常现象,不同区块间nonce波动很大 |
| 篡改交易后校验仍然通过 | 篡改的是内存副本但链验证走了原对象 | 修改后调用对应对象的isChainValid,确保对象一致 |
| 编译报错找不到picosha2.h | 头文件路径不对 | 把picosha2.h放到源文件同目录,或通过-I参数指定路径 |
| 忘记把新区块push进向量 | 链上永远只有创世区块 | 检查addBlock末尾是否有chain_.push_back(newBlock) |
| 打印hash和重新计算的hash不一样 | 打印时拼错了字段或使用了旧缓存 | 用calculateHash()重新计算后比对,确认nonce、候选串都没变 |
这张表里的每一个问题我都真实遇到过。尤其是“篡改后校验仍然通过”这个,原因是main函数里我的链是用const接口拿到的,直接修改不合法,我复制了一个副本再去改,结果验证时没有重新计算副本的哈希,导致误以为篡改生效了。后来把代码理顺,确保修改和验证操作在同一个对象上执行,问题才解决。
5. 从教学Demo到真实区块链:还差什么
写完这个最小的C++区块链原型,你会发现“区块链”这个概念的门槛其实没那么高。但也要清醒认识到,教学Demo和真实系统之间还隔着好几座大山。
5.1 原型系统与生产系统的核心差距
- 没有P2P网络。我的区块链完全跑在单进程里,所有区块都存在一个std::vector里。真实系统里,每个节点都有自己的链副本,通过P2P网络同步交易和区块。
- 没有共识协议。真实链上,多个节点可能同时挖出高度相同的区块,这时需要通过“最长链规则”或者其他共识算法让全网达成一致。教学Demo没有这个机制,谁本地添加了区块谁说了算。
- 没有Merkle树。真实区块不会把全部交易直接存进区块体,而是把交易哈希逐层合并,最终形成一个Merkle根,大大节省验证开销。我的代码里是裸存std::vector std::string ,直观但低效。
- 没有交易签名和余额校验。这个差距非常核心:我的代码里“Alice -> Bob 10”只是一行字符串,没有任何密码学机制证明这条交易确实是Alice发起的,也没有校验Alice的余额是否足够。真实区块链里,每笔交易都要有发送方的数字签名,节点会执行脚本校验签名和UTXO,才能判断交易是否合法。
- 没有持久化存储。我每次运行程序都从创世区块重新开始,真实区块链会把区块数据写入硬盘数据库,比如LevelDB或RocksDB,节点重启后才能继续同步。
5.2 后续可以继续尝试的方向
如果想把项目继续往下做,有几个性价比很高的方向:
- 给区块增加动态难度调整:统计最近若干个区块的平均出块时间,动态修改difficulty,模拟真实系统的难度调整机制。
- 引入Merkle树:把交易列表改成一个交易树,区块只存根哈希,同时实现简单的SPV验证逻辑。
- 用socket写一个极简P2P层:让两个进程可以互相广播交易和区块,每方维护自己的链副本,接受最长链。
- 加入真实签名:用OpenSSL生成密钥对,对交易内容签名,节点验证签名后再打包。这一步做完,你对“数字资产”的认知会完全不同。
- 上一套持久化:把链保存到文件或SQLite,重启后能加载历史区块,不再每次重新挖创世区块。
坦白说,写这个项目的最大收获,不完全是“我写出了一条链”,而是我终于能回答那个最基础的问题:为什么区块链叫“链”?因为每个区块都保存着前一个区块的哈希,这种引用关系让篡改的成本变成指数级上升。当你亲眼看到自己篡改一个交易字符串后整条链校验失败时,那些挂在嘴边的概念才会真正长在脑子里。我之前以为“不可篡改”是技术上的不可能,实际做完才明白,它更多是“规则上的不划算”,这个认知只有亲手实现过一遍才会形成。
最后再多说一句:如果你决定把这个demo敲一遍,请一定亲手做一次篡改测试。不要只看我的运行结果,而是自己改一行代码,看哈希怎么变、校验怎么失败。这个动作比读十篇文章都有用。等你真的打印出第一个全部是0000开头的哈希时,你会知道我在说哪种感觉。