[编程开发] C 语言中,「.」与「->」有什么区别?

[复制链接]
zjts 发表于 2024-1-19 23:10:42|来自:北京 | 显示全部楼层 |阅读模式
除了表达形式有些不同,功能可以说完全一样阿。那为何又要构造两个功能一样的运算符? 效率有差异?可是现在编译器优化都那么强了,如果真是这样岂不是有些多此一举
-----------------------------------------------
刚刚翻了下书,说早期的 C 实现无法用结构直接当作参数在函数间传递,只能用指向结构的指针在函数间进行传递!我想这应该也是最直观的原因吧。
全部回复5 显示全部楼层
dodolook 发表于 2024-1-19 23:10:47|来自:北京 | 显示全部楼层
首先功能是不一样的,结构体用点,结构体指针用箭头。
我们约定一下符号和名称:用 s, structure 表示一个结构体,p, pointer 表示结构体的指针。又假设有一个成员叫做 member。
为何又要构造两个功能一样的运算符?
为何要构造出这两个功能类似的运算符呢?为什么不都用一个.,指针就写成(*p).member ?
简单的说,就是一个快捷方式,一个语法糖。
但是这两个操作在 C 语言历史上的出现,没有先后(反对 @farseerfc 的说法)。

下面,就要开始考古了。
C 语言最初只是一个名为 UNIX 的操作系统的工具。UNIX 是当今包括 Linux,Android,iOS,macOS 等这些你听过的或者没听过的操作系统的设计原型。
C 语言的发展可以分为:

  • 早期 C 语言(1972~1973),出现在了 UNIX 操作系统里,UNIX 此时是第二个版本
  • 早期 C 语言(1973~1978),设计出了struct结构体,UNIX 此时是第三个版本
  • K&R 一书出现后(1978~1989),有了非正式的规范
  • ANSI C 和 ISO C 后(1989~1999),国家级乃至国际规范的出现
  • C99(1999~2011)
  • C11(2011~现在)
本文主要聚焦在 1 和 2 的交界上。
C 语言在诞生之初只是为 UNIX 操作系统设计的一个工具而已,这时 UNIX 也才是刚出来。刚被创造出来 C 语言,没有 struct 结构体,但是对于现代的程序员来说,这个风格已经非常熟悉了。这是来自 UNIX v2 的,复制文件的cp命令的源代码(许可证见文末,下同)(来自 此处):
main(argc,argv)
char **argv;
{
char buf[512];
int fold, fnew, n;
char *p1, *p2, *bp;
int mode;
        if(argc != 3) {
                write(1,"Usage: cp oldfile newfile\n",26);
                exit();
        }
        if((fold = open(argv[1],0)) < 0){
                write(1,"Cannot open old file.\n",22);
                exit();
        }
        fstat(fold,buf);
        mode = buf[2] & 037;
        if((fnew = creat(argv[2],mode)) < 0){
                stat(argv[2], buf);
                if((buf[3] & 0100) != 0){
                        p1 = argv[1] - 1;
                        p2 = argv[2] - 1;
                        bp = buf - 1;
                        while(*++bp = *++p2);
                        *bp = '/';
                        p2 = bp;
                        while(*++bp = *++p1)
                                if(*bp == '/')
                                        bp = p2;
                        if((fnew = creat(buf,mode)) < 0){
                                write(1,"Cannot creat new file.\n",23);
                                exit();
                        }
                }else{
                write(1,"Cannot creat new file.\n",23);
                exit();
                }
        }
        while(n = read(fold, buf, 512))
        if(n < 0){
                write(1,"Read error\n",11);
                exit();
        }else
                if(write(fnew,buf,n) != n){
                        write(1,"Write error.\n",13);
                        exit();
                }
        fstat(fnew,buf);
        exit();
}不能用结构体的代码,遇到了数组,就各类信息都开一个数组。上面那样复制一下文件还好说,一旦写起算法,代码看起来就很难受。

过了一年,1973 年前后,UNIX v3 的 C 语言编译器中第一次开始出现和处理 struct 的语法,但编译器自身此时还没有用 struct。那么 C 编译器此时是怎么处理和理解新添加的类型 struct 的.和->(仅有的)两种操作呢? 见下面的编译器代码片段(来自 此处):
build(op) {
...
        switch (op) {
...
        case 39:        /* . (structure ref) */
        case 50:        /* -> (indirect structure ref) */
                if (p2[0]!=20 | p2[3]!=4)                /* not mos */
                        error("Illegal structure ref");
                *cp++ = p1;
                t = t2;
                if ((t&030) == 030)        /* array */
                        t = decref(t);
                setype(p1, t);
                if (op==39)                /* is "." */
                        build(35);        /* unary & */
                *cp++ = block(1,21,7,0,p2[5]);
                build(40);                /* + */
                if ((t2&030) != 030)        /* not array */
                        build(36);        /* unary * */
                return;
        }
...
}(注:mos 是指 member of structure。)
首先值的注意的是, 39 号操作.和 50 号操作->都是共用这一段处理代码。
build(40);这一行的 40 号操作是加法。往上三行 if(op==39) build(35);这里的 35 号操作是 &。
换句话说:

  • 编译器会将p->member变成访问p+offset_member这个内存地址的变量
  • 编译器会将s.member变成访问&s+offset_member这个内存地址的变量
这么看来,p->member反而更加直接?
没错,所以,s.member等价于(&s)->member。反之:(*p).member则是(&(*p))->member,即是p->member,解了指针又取指针,绕回来了。

说点题外话

为什么地址是编译器最后需要的?
这反映了 CPU 里寻找一个结构体的成员的地址的本质:一个结构体是一个内存区域,但是真实的情况是程序需要记住这个内存区域的首地址。有了开头的地址,我们就可以加上位移去访问它的第二个成员,第三个成员,……
这和普通的变量是相同的概念:对于一个内存中的变量,比如整数i,运行的时候 CPU 需要知道&i是多少,才能存取i。

设计出 struct 之后的世界……
UNIX v3 开发出了具有 struct 功能的编译器,UNIX 随后的版本里就立刻用上了。1973 年出现的这个语法后来在 1975 年的 UNIX v6 里的文档 C Reference Manual 第七页里出现了:
7.1.7  primary‐lvalue . member‐of‐structure
An lvalue expression followed by a dot followed by the name of a
member  of  a  structure is a primary expression.  The object re‐
ferred to by the lvalue is assumed to have the same form  as  the
structure containing the structure member.  The result of the ex‐
pression is an lvalue appropriately offset from the origin of the
given  lvalue  whose  type is that of the named structure member.
The given lvalue is not required to have any particular type.
Structures are discussed in §8.5.

7.1.8  primary‐expression −> member‐of‐structure
The primary‐expression is assumed to be a pointer  which  points
to  an object of the same form as the structure of which the mem‐
ber‐of‐structure is a part.  The result is an lvalue appropriate‐
ly  offset from the origin of the pointed‐to structure whose type
is that of the named structure member.  The type of the  primary‐
expression  need not in fact be pointer; it is sufficient that it
be a pointer, character, or integer.
Except for the relaxation of  the  requirement  that  E1  be  of
pointer type, the expression “E1−>MOS” is exactly equivalent to
“(*E1).MOS”.(可见此处)
最后一段说:
primary-expression 对类型不做过多的限定,除此之外 E1->MOS 完全等价于 (*E1).MOS。
这里的“不做过多限定”是指,(当时的)C 语言里p->member的p甚至可以是一个整数类型等。
工程师就喜欢层层封装,结构体正是最典型的封装之一。对于 UNIX 这样一个操作系统工程来说,结构体提供了非常丰富的语义。UNIX v3 之后,UNIX 的几乎各个部分迅速改用 C 语言编写,UNIX 代码仓库的内容也开始爆炸式地丰富起来,这其中或许 struct 的出现也有着不可磨灭的功劳。
(正文完)



附:适用于本文的 UNIX 操作系统源码许可证
版版有王暴 发表于 2024-1-19 23:10:52|来自:北京 | 显示全部楼层
这个在C语言的确是遗留问题,从语法层面上, 和 ->是等价的,或者说编译器优化后等价。
其实严格抠标准,也不是完全等价,详见 @Milo Yip 的回答。
我补充一点:在访问空指针和野指针时有差别——空指针会挂在*解引用,野指针会挂在.访问内存。
但从语义层面上,这两者的确是有区别的,原因也源于C语言早期的设计思路,虽然不是题主补充的那样。

假设我们有个结构体
typedef struct {
    int x;
    int y;
} Point;
Point pos;
pos.x = 10;
pos.y = 5;
Point* pPos = &pos;
(*pPos).x = 15;
pPos->y = 20;由于C语言早期是作为汇编的通用翻译存在的,而从汇编或者说从机器的角度,就只存在三个概念——指令、寄存器、内存。
其中,寄存器的输入又是来自内存,是和指令打包使用的,所以其实相当于指令+寄存器打包为“代码”的概念,内存为“数据”的概念。
而内存是连贯的二进制数据,并不存在实际的类型和结构,编程语言里的类型,只是对内存的抽象。
因此, 从机器角度,是类似下面这样的(为方便理解,我也把它转换成c写法)
*(int*)(ADDRESS(pos) + OFFSETOF(Point, x))即【取变量p的地址,偏移四个字节,将新地址作为int类型使用】

在这样的语义下,我们操作指针变量pPos时,如果写作 ,按照上面举的例子,这个内存映射就完全不对了,所以必须先增加一个 来解引用。
然后,为了统一语法形式,同时简化编写,所以增加了一个   操作符的间接版本,就是 ->
所以,如果从语义上解释,这两个操作符的含义是这样的:
. 直接成员访问操作符
-> 间接成员访问操作符

当然,随着编译器优化,现代编译器已经可以识别代码实际要做的事,而不是粗暴的做翻译了,所以完全可以把 和 -> 合并,统一用   表示。
但我不建议这么做,这样语义上存在一定的模糊,或者说语义范围的扩大,与C语言简洁清晰的设计逻辑有悖。

================

而到了C++中,早期为了兼容C,全盘继承了这些东西,但在C++特性丰富起来以后,反而亏得当时没有把两个操作符合并,在后续版本中,才能通过更加丰富灵活的语法,将这两者的语义间的差异作出进一步的诠释。
说人话就是,运算符重载

≈≈≈≈ 偏个题 ≈≈≈≈
运算符重载通过不修改语法来扩展语义的形式,使得C++在许多领域,依旧比绝大多数更新更高级的语言好用得多,从代码编写和编译运行两个角度同时体现了零开销抽象的设计哲学的美感。
比如向量乘法, 相比 或者 ,语法上与基础类型乘法获得完美统一,语义上也统一。
≈≈≈≈ 偏题结束 ≈≈≈≈

在允许运算符重载的C++中,结合模板技术,出现了智能指针这个玩意。
std::shared_ptr<Point> pPos = std::make_shared<Point>();
这个时候,pPos. 和 pPos-> 可就是完全不同的概念了,这两者把两个运算符的语义差异体现的淋漓尽致:
pPos. 直接成员访问操作符,操作shared_ptr这个对象本身的成员和方法,比如.use_count()操作的就是shared_ptr对象本身
pPos-> 间接成员访问操作符,操作指针指向的对象的成员,如通过->a才是访问Point对象的x。
hmmm,这里还可以说是语言缺陷,因为零开销抽象和兼容C等设计约束和历史原因,无法像其他高级语言那样把指针语义整合到语言内部,所以才不得不做了智能指针类,不得不弄出一堆成员方法来扩展指针语义。

那么我再举个例子吧,迭代器。
QMap<int, Point> map;
auto it = map.begin();
it.value().x = 5;
it->x = 10;
这里就是运算符重载如何体现两种成员访问操作符的语义了。
这里我用QMap而不是std::map来举例,因为std::map的迭代器一般实现为std::pair*指针,语义的挖掘和使用的灵活性上个人觉得不如Qt。
由于map的迭代器,必须能够同时访问key和value,所以它必须具有key和value两个成员。
于此同时,迭代器的表现又应该类似于value*指针,可以通过*直接获得value,或者通过->访问value内容。这样和vector等容器、常规数组的迭代器表现取得一致,便于配合std::begin()/std::end()设计基于迭代器的通用算法。
此时,如果把两种成员访问操作符合并,在这个场景下,语义就会出现混淆。
东来西往 发表于 2024-1-19 23:11:51|来自:北京 | 显示全部楼层
还是有一点细微的区别,参考 Member access operators:
Member access(
The member access expression designates the named member of the struct or union designated by its left operand. It has the same value category as its left operand.
Member access through pointer(
The member access through pointer expression designates the named member of the struct or union type pointed to by its left operand. Its value category is always lvalue.
就是说 可能是 lvalue 也可能是 non-lvalue(按 a 决定);而 必然是 lvalue。
typedef struct {
    int b;
}A;

A f() {
    A a = { 1 };
    return a;
}

A* g() {
    static A a = { 1 };
    return &a;
}

int main() {
    A a;
    a.b = 2;    // a is lvalue, a.b is lvalue, OK
    f().b = 2;  // f() returns a non-lvalue, f().b is non-lvalue, compilation error
    g()->b = 2; // g()->a is always lvalue, OK
}不过这种区别几乎可以忽略。
一般使用来说,就是 写起来比 简单一些。
zxhy 发表于 2024-1-19 23:12:35|来自:北京 | 显示全部楼层
->告诉你这里有个指针,要注意!
y007 发表于 2024-1-19 23:13:35|来自:北京 | 显示全部楼层
首先 a->b 的含義是 (*a).b  ,所以他們是不同的,不過的確 -> 可以用 * 和 . 實現,不需要單獨一個運算符。
嗯,我這是說現代的標準化的 C 語義上來說, -> 可以用 * 和 . 的組合實現。
早期的 C 有一段時間的語義和現代的 C 的語義不太一樣。
稍微有點彙編的基礎的同學可能知道,在機器碼和彙編的角度來看,不存在變量,不存在 struct 這種東西,只存在寄存器一個叫做內存的大數組
所以變量,是 C 對內存地址的一個抽象,它代表了一個位置。舉個例子,C 裏面我們寫:
a = b
其實在彙編的角度來看更像是
*A = *B
其中 A 和 B 各是兩個內存地址,是指針。
好,以上是基本背景。
基於這個背景我們討論一下 struct 是什麼,以及 struct 的成員是什麼。
假設我們有
struct Point {
int x;
int y;
};
struct Point p;
struct Point *pp = &p;
從現代語義上講 p 就是一個結構體對象, x 和 y 各是其成員,嗯。
從彙編的語義上講, p 是一個不完整的地址,或者說,半個地址,再或者說,一個指向的東西是虛構出來的地址。而 x 和 y 各是在 Point 結構中的地址偏移量。也就是說,必須有 p 和 x 或者 p 和 y 同時出現,才形成一個完整的地址,單獨的一個 p 沒有意義
早期的 C 就是在這樣的模型上建立的。所以對早期的 C 而言, *pp 沒有意義,你取得了一個 struct ,而這個 struct 不能塞在任何一個寄存器裏,編譯器和 CPU 都無法表達這個東西。
這時候只有 p.x 和 p.y 有意義,它們有真實的地址。
早期的 C 就是這樣一個看起來怪異的語義,而它更貼近機器的表達。
所以對早期的 C 而言,以下的代碼是對的:
p.x = 1;
int *a;
a = &(p.x);
而以下代碼是錯的:
(*pp).x = 1;
因爲作爲這個賦值的目標地址表達式的一部分, *pp,這個中間結果沒法直譯到機器碼。
所以對早期的 C 而言,對 pp 解引用的操作,必須和取成員的偏移的操作,這兩者緊密結合起來變成一個單獨的操作,其結果纔有意義。
所以早期的 C 就發明了 -> ,表示這兩個操作緊密結合的操作。於是纔能寫:
pp->x = 1;
嗯,這就是它存在的歷史原因。
而這個歷史原因現在已經不重要了,現代的符合標準的 C 編譯器都知道 (*pp).x 和 pp->x 是等價的了。
說句題外話, C++ 裏面還發明了 .* 和 ->* 這兩個運算符(注意 ->* 不是單獨的 -> 和 * 並排放的意思),關於爲什麼要發明這兩個運算符,而不能直接說 a ->* b 的意思就是 a ->(*b) ,這個就作爲課堂作業吧。

快速回帖

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则