从汇编看c++的new和delete
下面是c++源码:
class X { private: int _x; public: X(int xx = 0) : _x(xx) {} ~X() {} }; int main() { X* xp = new X; delete xp; }
代码很简单,在main函数里面先用new构造一个堆对象,然后用delelte释放此对象。
接下来看构造堆对象的汇编码:
: X* xp = new X; push 4;压入对象的大小4byte,为调用operator new函数传递参数 call ??2@YAPAXI@Z ; 调用operator new函数 add esp, 4;operator new调用结束,堆栈指针下移4byte,释放为operator new的参数分配的栈空间 mov DWORD PTR $T2579[ebp], eax;寄存器eax里面存放申请到的堆空间首地址,存入临时变量ST2579 cmp DWORD PTR $T2579[ebp], 0;将临时变量ST2579的值与0比较,即检测申请的对空间首地址是否为NULL je SHORT $LN3@main;如果检测结果相等,就跳转到标号$LN3@main处执行,否则顺序执行。这里是顺序执行 push 0;将0压栈,为调用构造函数传递参数 mov ecx, DWORD PTR $T2579[ebp];将临时变量ST2579的值(里面保存申请到的堆空间首地址)给寄存器ecx,作为隐含参数传递给构造函数 ;这个隐含参数就是this指针 call ??0X@@QAE@H@Z ; 调用对象的构造函数 mov DWORD PTR tv70[ebp], eax;构造函数调用完毕,寄存器eax里面存放的是对象首地址,这里将首地址给临时变量tv70 jmp SHORT $LN4@main;跳转到标号$LN4@main处执行 $LN3@main: mov DWORD PTR tv70[ebp], 0;如果申请对空间失败,将0给临时变量tv70 $LN4@main: mov eax, DWORD PTR tv70[ebp];将tv70的值给寄存器eax mov DWORD PTR _xp$[ebp], eax;将寄存器eax的值给指针变量xp
从汇编码可以看到,用new构造堆对象时,大致的流程是:
1 调用operator new申请堆空间
2 对申请到的堆空间首地址进行检查,防止申请失败,返回空指针
3 如果申请堆空间成功,就调用构造函数 并将堆空间首地址给xp指针。
因此,c++中用new申请堆空间与用malloc不同,前者自动检测堆空间是否申请成功。
下面看析构堆对象的汇编码:
: delete xp; mov ecx, DWORD PTR _xp$[ebp];将xp指针的值(即堆对象首地址)给寄存器ecx mov DWORD PTR $T2591[ebp], ecx;将ecx的值给临时变量ST2591 mov edx, DWORD PTR $T2591[ebp];将临时变量ST2591的值给寄存器edx mov DWORD PTR $T2590[ebp], edx;将寄存器edx里面的值给临时变量ST2590 cmp DWORD PTR $T2590[ebp], 0;将临时变量ST2590的值与0比较,即检测传进来的堆对象首地址是否为空指针 je SHORT $LN5@main;如果为空指针,则跳转到标号$LN5@main处执行,否则,顺序执行。这里顺序执行 push 1;压入对象类型标志 1 单个对象 3 对象数组 0 只调用析构函数,不调用释放堆空间(在多重继承时有用)
mov ecx, DWORD PTR $T2590[ebp];将临时变量ST2590的值给寄存器ecx,作为隐函数参数(this指针)传递给析构代理函数 call ??_GX@@QAEPAXI@Z;调用析构代理函数 mov DWORD PTR tv75[ebp], eax;析构代理函数执行完毕,寄存器eax里面保存堆对象首地址,将eax的值给临时变量tv75 jmp SHORT $LN1@main;跳转到标号$LN1@main处执行 $LN5@main: mov DWORD PTR tv75[ebp], 0;如果检测堆对象首地址失败,临时变量tv75的值赋0 $LN1@main:;标号后面是main函数结束时的代码
从汇编码可以看到,调用delete并不像operator new一样直接调用析构函数和operator delete函数,而是调用了析构代理函数来完成operator delete的功能。之所以要用代理函数,是因为某些情况下要释放的对象不止一个。
下面是析构代理函数汇编码:
??_GX@@QAEPAXI@Z PROC ; X::`scalar deleting destructor', COMDAT ; _this$ = ecx push ebp mov ebp, esp push ecx;寄存器ecx里面存储的是对对象首地址,这里压栈的目的是为了保存这个地址预留空间 mov DWORD PTR _this$[ebp], ecx;将ecx里面的值存到刚才预留的空间里面 mov ecx, DWORD PTR _this$[ebp];将堆对象首地址给寄存器ecx,作为隐含参数传递给析构函数 call ??1X@@QAE@XZ ; 调用析构函数 mov eax, DWORD PTR ___flags$[ebp];这里获取在调用析构代理函数之前,传进来的标志,并将其给寄存器eax and eax, 1;将eax里面(对象类型标志)的值与1相与 目的是判断是否调用delete函数释放堆空间
je SHORT $LN1@scalar;如果相与的结果为0,则跳到标号$LN1@scalar处执行,否则,顺序执行,这里顺序执行 mov ecx, DWORD PTR _this$[ebp];将堆对象首地址作为给寄存器ecx push ecx;压栈ecx的值,为调用delete函数传递参数 call ??3@YAXPAX@Z ; 调用delete函数 add esp, 4;delete函数调用完毕,栈顶指针下移4byte,释放为delete函数的参数分配的栈空间 $LN1@scalar: mov eax, DWORD PTR _this$[ebp];将堆对象首地址给寄存器eax
;做为返回值
mov esp, ebp pop ebp ret 4 ??_GX@@QAEPAXI@Z ENDP
从汇编码可以看到,析构代理函数先调用真正的析构函数,然后再调用delete函数释放申请到的堆空间。在这中间有一个判断过程,即通过对象类型标志,判断是否调用operator delete释放堆空间。
通过上面的汇编码,可以看到delete的流程大致是:
1 检测对象首地址值是否为空
2 如果不为空,就调用析构代理函数,否则,就不调用析构代理函数
3 在析构代理函数里面调用对象的析构函数和delete函数(如果对象标志不为0的话)
从对c++中的delete调用过程来看,delete不会自动的将指针变量xp的值清零。因此,后续程序中如果使用xp指针仍然可行。指针变量xp和xp所指向的对象,最大的差别就是哪一个声明已经结束了。调用delete后,xp所指向的对象已将被析构,变得不合法,但是地址本身仍然代表一个合法的程序空间。
对象数组
下面来看new和delete应用于对象数组的情况
c++源码如下:
class X { private: int _x; public: X(int xx = 0) : _x(xx) {} ~X() {} }; int main() { X* xp = new X[2]; delete [] xp; }
上述代码在用new在堆中构造了2个对象,然后通过delete释放。
下面里看构造过程汇编码:
X* xp = new X[2]; 00F035BD push 0Ch ;为调用operator new运算符传递参数,即要申请的堆空间的大小 ;这里每个对象只有4byte,但是却要申请12byte,是因为对于申请数组对象 ;堆空间首地址4byte用来存储对象个数 00F035BF call operator new ;调用operator new运算符 00F035C4 add esp,4 ;operator new调用结束,栈顶指针下移4byte,释放为operator new传递参数而分配的栈空间 00F035C7 mov dword ptr [ebp-0F8h],eax;寄存器eax里面保存了申请到的堆空间首地址 00F035CD mov dword ptr [ebp-4],0 ; 00F035D4 cmp dword ptr [ebp-0F8h],0 ;将返回的堆空间首地址与0比较,检测是否申请成功 00F035DB je main+97h (0F03617h) ;如果申请不成功,即返回NULL,则跳到地址0F03617h处执行 否则,顺序执行 这里顺序执行 00F035DD mov eax,dword ptr [ebp-0F8h] ;将堆空间首地址给寄存器eax 00F035E3 mov dword ptr [eax],2 ;将2写入堆空间首地址所在内存,即在堆空间首地址4byte处保存对象个数 00F035E9 push offset X::~X (0F011DBh) ;将析构函数地址压栈,作为构造代理函数参数 00F035EE push offset X::`default constructor closure' ;将构造函数地址压栈,作为构造代理函数参数 00F035F3 push 2 ;将对象个数压栈,作为构造代理函数参数 00F035F5 push 4 ;将对象大小压栈,作为构造代理函数参数 00F035F7 mov ecx,dword ptr [ebp-0F8h] ;将堆空间首地址给寄存器ecx 00F035FD add ecx,4 ;ecx里面的值加4 即跳过堆空间首地址4byte,此时ecx里面保存的是第一个堆对象首地址 00F03600 push ecx ;压栈ecx,作为构造代理函数参数 00F03601 call `eh vector constructor iterator' (0F011E5h) ;调用构造代理函数 00F03606 mov edx,dword ptr [ebp-0F8h] ;将堆空间首地址给寄存器edx 00F0360C add edx,4 ;edx里面的值加4 即跳过堆空间首地址4byte,此时edx里面保存的是第一个堆对象首地址 00F0360F mov dword ptr [ebp-10Ch],edx 00F03615 jmp main+0A1h (0F03621h) ;跳到地址0F03621h处执行 00F03617 mov dword ptr [ebp-10Ch],0 ;如果堆空间申请失败,赋空指针 00F03621 mov eax,dword ptr [ebp-10Ch] 00F03627 mov dword ptr [ebp-104h],eax 00F0362D mov dword ptr [ebp-4],0FFFFFFFFh 00F03634 mov ecx,dword ptr [ebp-104h] 00F0363A mov dword ptr [ebp-14h],ecx ;堆空间中第一个堆对象首地址给了xp
上面的汇编码流程大致是:
1 调用operator new分配堆空间
2 调用构造代理函数构造堆对象,在调用构造代理函数时,通过压栈,像其传递了5个参数,分别是 a)第一个堆对象首地址 b)堆对象大小
c)堆对象个数 d)构造函数地址 e)析构函数地址
3 传回第一个堆对象首地址,而不是申请到的堆空间首地址
从上面汇编码中还可以看到,申请到的堆空间首地址用来存放的是对象的个数,因此,堆空间总大小并不是对象大小 * 对象个数
下面是构造代理函数的汇编码,只看相关部分:
00F03792 mov dword ptr [ebp-20h],0 00F03799 mov dword ptr [ebp-4],0 00F037A0 mov dword ptr [ebp-1Ch],0 ;将该内存的值初始化为0 相当于for循环中,循环变量初始化为0 00F037A7 jmp `eh vector constructor iterator'+52h (0F037B2h) ;跳转到地址0F037B2h处执行 00F037A9 mov eax,dword ptr [ebp-1Ch] ;循环变量的值给寄存器eax 00F037AC add eax,1 ;寄存器eax里面的值加1,即循环变量加1 00F037AF mov dword ptr [ebp-1Ch],eax ;将循环变量保存到内存中 00F037B2 mov ecx,dword ptr [ebp-1Ch] ;循环变量值给了寄存器ecx 00F037B5 cmp ecx,dword ptr [ebp+10h];将ecx里面的值和 dword ptr [ebp+10h]内存所代表的值(对象个数2)比较,相当于循环变量的比较 00F037B8 jge `eh vector constructor iterator'+6Bh (0F037CBh) ;如果循环变量的值大于等于2,就跳转到地址0F037CBh执行,否则顺序执行 00F037BA mov ecx,dword ptr [ebp+8] ;将第一个堆对象对象首地址给寄存器ecx,作为隐含参数给构造函数 00F037BD call dword ptr [ebp+14h] ;调用构造函数 00F037C0 mov edx,dword ptr [ebp+8] ;将第一个堆对象首地址给寄存器edx 00F037C3 add edx,dword ptr [ebp+0Ch] ;edx里面的值加上对象大小,即修改指针,指向下一个堆对象首地址 00F037C6 mov dword ptr [ebp+8],edx ;堆对象首地址首地址保存到内存中 00F037C9 jmp `eh vector constructor iterator'+49h (0F037A9h);调转到地址0F037A9h处执行 ;======================下面还有代码,但是是完成构造函数之后,因此省略========================
构造代理函数的作用就是循环调用构造函数,依次构造数组中的每一对象
下面是释放数组对象的汇编码:
delete [] xp; 00A2363D mov eax,dword ptr [ebp-14h];将堆空间中第一个对象首地址给寄存器eax ;接下来是对eax的值的传递过程 00A23640 mov dword ptr [ebp-0E0h],eax 00A23646 mov ecx,dword ptr [ebp-0E0h] 00A2364C mov dword ptr [ebp-0ECh],ecx ;最后eax的值(即堆空间第一个对象首地址)给了ebp-0ECh所在内存 00A23652 cmp dword ptr [ebp-0ECh],0 ;检测该内存里面的值是否为空 00A23659 je main+0F0h (0A23670h) ;如果为空,跳到地址0A23670h处执行,否则,顺序执行 这里是顺序执行 00A2365B push 3 ;压入释放对象类型标志,1为单个对象,3为释放对象数组,0仅表示执行析构函数,不释放堆空间 ;压入的值将作为参数传递给析构代理函数 00A2365D mov ecx,dword ptr [ebp-0ECh] ;将堆空间中第一个对象首地址给寄存器ecx,作为隐含参数传递给析构代理函数 00A23663 call X::`vector deleting destructor' (0A211EAh) ;调用vector deleting destructor函数
00A23668 mov dword ptr [ebp-10Ch],eax 00A2366E jmp main+0FAh (0A2367Ah) 00A23670 mov dword ptr [ebp-10Ch],0 13: 14: }
vector deleting destructor函数的汇编码:
X::`vector deleting destructor': 00A21B10 push ebp 00A21B11 mov ebp,esp 00A21B13 sub esp,0CCh 00A21B19 push ebx 00A21B1A push esi 00A21B1B push edi 00A21B1C push ecx ;在调用该函数之前,ecx寄存器保存的是堆空间中,第一个对象的首地址 ;这里将首地址压入栈中保存,因为下面的代码中将用到寄存器ecx 00A21B1D lea edi,[ebp-0CCh] 00A21B23 mov ecx,33h 00A21B28 mov eax,0CCCCCCCCh 00A21B2D rep stos dword ptr es:[edi] ;==========================================以上代码为函数入口部分==================== 00A21B2F pop ecx ;将栈顶里面的值(保存着堆空间中第一个对象首地址)弹出,存到寄存器ecx, 00A21B30 mov dword ptr [ebp-8],ecx ;将对象首地址存放到ebp-8所代表的内存 00A21B33 mov eax,dword ptr [ebp+8] ;对象类型标标志被保存在了寄存器eax中 00A21B36 and eax,2 ;将eax里面的值和2相与,检测是否为对象数组标志(因为标志只能为0 1 3,如果不为3 结果肯定为0) 00A21B39 je X::`vector deleting destructor'+61h (0A21B71h);如果结果为0,即不是对象数组标志 ;跳转到地址0A21B71h处执行 否则 顺序执行 这里顺序执行 00A21B3B push offset X::~X (0A211DBh);将析构函数的地址压栈,作为参数传递给析构代理函数 00A21B40 mov eax,dword ptr [this] ;this指针指向堆空间中第一个对象首地址,这里将该值给寄存器eax 00A21B43 mov ecx,dword ptr [eax-4] ;将向上偏移堆空间中第一个对象首地址4byte处内存内容(即申请到的堆空间首地址处 ;内存内容,该内存里面保存着对象个数)给寄存器ecx 00A21B46 push ecx ;将ecx压栈,作为参数传递给析构代理函数 00A21B47 push 4 ;将对象大小压栈,作为参数传递给析构代理函数 00A21B49 mov edx,dword ptr [this] ;将堆空间中第一个对象首地址给寄存器edx 00A21B4C push edx ;将edx的值压栈,作为参数传递给析构代理函数 00A21B4D call `eh vector destructor iterator' (0A211F4h) ;调用析构代理函数 00A21B52 mov eax,dword ptr [ebp+8] ;获取对象类型标志,其值在调用该函数之前被压入栈中 00A21B55 and eax,1;将寄存器eax的值和1相与,目的是判断是否要调用delete函数释放堆空间 00A21B58 je X::`vector deleting destructor'+59h (0A21B69h) ;如果相与结果为0,就跳转到地址0A21B69h处执行,否则 ;顺序执行,这里顺序执行 00A21B5A mov eax,dword ptr [this] ;将堆空间中第一个对象首地址给寄存器eax 00A21B5D sub eax,4 ;将eax里面的值减4,即修正了eax里面的值,此时,eax里面保存的是申请到的堆空间首地址 00A21B60 push eax ;将eax的值压栈,作为隐含参数传递给operator delete 00A21B61 call operator delete (0A21087h) ;调用operator delete,释放堆空间 00A21B66 add esp,4 ;调用operator delete结束,释放为其传参时的栈空间 00A21B69 mov eax,dword ptr [this];将堆空间中第一个对象首地址给寄存器eax 00A21B6C sub eax,4 ;将eax的值减4,即修正eax里面的值,此时,eax里面存储的是申请到的堆空间首地址 00A21B6F jmp X::`vector deleting destructor'+80h (0A21B90h) ;跳转到地址0A21B90h处执行 ;=====================下面代码是传进来的对象标志不是3时执行的代码===================== 00A21B71 mov ecx,dword ptr [this] ;如果对象标志不是3,将跳转到这里执行。将堆空间中对象首地址给寄存器ecx,作为隐含参数调用析构函数 00A21B74 call X::~X (0A211DBh) ;调用析构函数 00A21B79 mov eax,dword ptr [ebp+8] ;将对象类型标志给寄存器eax 00A21B7C and eax,1 ;和上面一样,判断是否释放堆空间 00A21B7F je X::`vector deleting destructor'+7Dh (0A21B8Dh) ;如果对象标志为0,就跳转到地址0A21B8Dh处执行 ;否则,顺序执行 00A21B81 mov eax,dword ptr [this] ;将堆对象首地址给寄存器eax 00A21B84 push eax ;压入eax,作为operator delete的参数 00A21B85 call operator delete (0A21087h) ;调用operator delete 00A21B8A add esp,4 ;operator delete调用结束,释放为其传递参数分配的栈空间 00A21B8D mov eax,dword ptr [this] ;将堆对象首地址给寄存器eax,作为返回值 00A21B90 pop edi 00A21B91 pop esi 00A21B92 pop ebx 00A21B93 add esp,0CCh 00A21B99 cmp ebp,esp 00A21B9B call @ILT+305(__RTC_CheckEsp) (0A21136h) 00A21BA0 mov esp,ebp 00A21BA2 pop ebp 00A21BA3 ret 4
vector deleting destructor总体流程也是先调用析构代理函数,然后调用operator delete释放空间(如果对象标志不为0的话),并且在调用析构代理函数时也传进4个参数 a)堆空间中第一个对象首地址 b)对象大小 c)对象个数 d)虚函数地址
下面是析构代理函数的汇编码:
mov ecx,dword ptr [ebp+0Ch] ;获取堆对象个数,给寄存器ecx
00A236E0 imul ecx,dword ptr [ebp+10h] ;ebp+10h所代表的内存里面存放对象大小,这条指令将ecx的值和ebp+10h
;的值相乘,将结果保存在ecx里面
;ecx里面此时保存的是所有堆对象所占大小
00A236E4 add ecx,dword ptr [ebp+8] ;ebp+8所代表的的内存存放堆空间中第一个对象首地址,这里将它与ecx相加
;结果保存在ecx里面,此时ecx存放的是堆空间中最后一个对象后面的内存地址
;这么做是为了从最后一个对象开始析构
00A236E7 mov dword ptr [ebp+8],ecx ;将ecx的值保存到ebp+8所代表的内存
00A236EA mov dword ptr [ebp-4],0
00A236F1 mov edx,dword ptr [ebp+10h] ;将对象个数给寄存器edx,相当于for循环里面的循环技术变量
00A236F4 sub edx,1 ;edx里面的值减1
00A236F7 mov dword ptr [ebp+10h],edx ;将edx里面的值存放到ebp+10h所代表的的内存
00A236FA js `eh vector destructor iterator'+6Dh (0A2370Dh) ;如果edx-1时为父,跳转到地址0A2370Dh处执行
00A236FC mov eax,dword ptr [ebp+8] ;将ebp+8内存的值(即最后一个堆对象后面的内存地址)给寄存器eax
00A236FF sub eax,dword ptr [ebp+0Ch] ;ebp+0ch所代表的内存存放对象大小,这里用eax-对象大小
;这里依次得到从最后一个堆对象到第一个堆对象首地址,
;存放到寄存器eax
00A23702 mov dword ptr [ebp+8],eax;将eax的值保存到ebp+8所代表的内存
00A23705 mov ecx,dword ptr [ebp+8];将堆对象首地址给ecx寄存器,作为隐含参数传递给析构函数
00A23708 call dword ptr [ebp+14h] ;ebp+14h所代表的内存里面存放的是析构函数地址,这里调用析构函数
00A2370B jmp `eh vector destructor iterator'+51h (0A236F1h);跳转到地址0A236F1h执行
;后面是析构完对象之后的结束代码
可以看到,析构代理函数也是循环调用析构函数,以与构造对象相反的顺序析构对象。
比较对单个堆对象调用delete和对堆对象数组调用delete[]可以发现,两种情况最主要的差别是在用最后用delete释放堆空间时,delete[]会对目标指针(即对delete来说,目标指针为堆对象首地址,对delete[]来说,是堆中第一个堆对象首地址)进行减4调整.因此,如果是释放单个对象堆空间,错误的使用delete[],那么,执行中间检测时,会判断对象类型标记为3,进行目标指针调整,结果会释放错误的堆空间。同理,如果释放对象数组,而错误的使用delet,那么,执行中间检测时,会判断对象类型标记为1,不进行目标指针调整,堆空间的释放也会发生错误。
基本数据类型
下面是c++源码:
class X { private: int _x; public: X(int xx = 0) : _x(xx) {} virtual ~X() {} }; class Y { private: int _y; public: Y(int yy = 0) : _y(yy) {} virtual ~Y() {} }; class Z : public X, public Y { private: int _z; public: Z(int zz = 0) : _z(zz){} virtual ~Z() {} }; int main() { int* ip1 = new int[2]; delete [] ip1; int* ip2 = new int; delete ip2; }
c++代码中ip1指向的是堆中基本类型int数组的首地址,ip1指向堆中单个基本类型int的首地址。
下面是mian函数里面的汇编码:
28: int* ip1 = new int[2]; 00141A3E push 8 ;将申请的堆空间大小压栈,作为参数传递给operator new 00141A40 call operator new (141177h) ;调用operator new 00141A45 add esp,4 ;栈顶指针减4 释放为调用operator new传参时分配的栈空间 00141A48 mov dword ptr [ebp-104h],eax ;寄存器eax里面存有申请到的堆空间首地址,下面 ;的代码都是一些传值操作 00141A4E mov eax,dword ptr [ebp-104h] 00141A54 mov dword ptr [ip1],eax ;eax的值传给了指针变量ip1 29: delete [] ip1; 00141A57 mov eax,dword ptr [ip1] ;将ip1的值(指向堆空间首地址)给寄存器eax 00141A5A mov dword ptr [ebp-0F8h],eax ;下面是一些传值操作 00141A60 mov ecx,dword ptr [ebp-0F8h] 00141A66 push ecx ;ecx里面保存了堆空间首地址,这里将ecx压栈,为调用delete传参 00141A67 call operator delete (141082h) ;调用delete 00141A6C add esp,4 ;栈顶指针减4,释放为调用delete传参时分配的栈空间 30: 31: int* ip2 = new int; 00141A6F push 4 ;将申请的堆空间大小压栈,作为餐宿传递给operator new 00141A71 call operator new (141177h);调用operator new 00141A76 add esp,4 ;栈顶指针减4 释放为调用operator new传参时分配的栈空间 00141A79 mov dword ptr [ebp-0ECh],eax ;寄存器eax里面存有申请到的堆空间首地址,下面 ;的代码都是一些传值操作 00141A7F mov eax,dword ptr [ebp-0ECh] 00141A85 mov dword ptr [ip2],eax ;eax的值给指针变量ip2 32: delete ip2; 00141A88 mov eax,dword ptr [ip2] ;将ip2的值(指向堆空间首地址)给寄存器eax 00141A8B mov dword ptr [ebp-0E0h],eax ;下面是一些传值操作 00141A91 mov ecx,dword ptr [ebp-0E0h] 00141A97 push ecx ;ecx里面保存了堆空间首地址,这里将ecx压栈,为调用delete传参 00141A98 call operator delete (141082h);调用delete 00141A9D add esp,4 ;栈顶指针减4,释放为调用delete传参时分配的栈空间
从汇编码可以看到,由于基本数据类型没有构造函数和析构函数,因此,这两种情况都只是简单的调用new分配空间,调用delete释放空间。并且还可以看到,和堆中对象数组不同,堆中基本类型数组没有在申请到的堆空间首地址处存放对象个数,ip1,ip2都直接指向的是各自申请到的堆空间首地址,正因为如此,对于基本类型,delete和delete[]效果一样。