C语言(C99标准)在结构体的初始化上与C++的区别

时间:2021-11-27 19:06:01

C++中由于有构造函数的概念,所以很多时候初始化工作能够很方便地进行,而且由于C++标准库中有很多实用类(往往是类模板),现代C++能十分容易地编写。

比如现在要构造一个类Object,包含两个字段,一个为整型,一个为字符串。C++的做法会像下面这样

#include <stdio.h>
#include <string> struct Object
{
int i;
std::string s;
Object(int _i, const char* _s) : i(_i), s(_s) { }
}; int main()
{
Object obj(1, "hello");
printf("%d %s\n", obj.i, obj.s.c_str());
return 0;
}

这样的代码简洁、安全,C++通过析构函数来实现资源的安全释放,string的c_str()方法能够返回const char*,而这个字符串指针可能指向一片在堆上动态分配的内存,string的析构函数能够保证string对象脱离作用域被销毁时,这段内存被系统回收。

string真正实现较为复杂,它本身其实是类模板basic_string的实例化,而且basic_string里面的类型都是用type_traits来进行类型计算得到的类型别名,通过模板参数CharT(字符类型)不同,相应的值也不同,但都是通过模板的手法在编译期就计算出来。比如字符类型CharT可以是char、char16_t、char32_t、wchar_t,对应的类模板实例化为string、u16string、u32string、wstring,共享类模板basic_string的成员函数来进行字符串操作。

string内部的优化措施也不同,像VS2015的basic_string就是采用字符串较短时c_str()指向栈上的字符数组、较长则动态分配的策略。其他系统有的可能采用写时复制技术,总之,一般而言string不会成为性能的瓶颈,符合C++既保证代码简洁又保证抽象带来的效率丢失尽可能小的设计要求。

对于C而言,就没有C++那么方便了。C一般是直接用字符数组来表示字符串,再用头文件<string.h>的函数来进行字符串操作。

字符数组是个麻烦东西,之前我写过一篇博客讨论数组与指针的区别。参见

数组与指针的区别,以及在STL中传递数组/指针

数组比起包装好的类,一个显著差异就是在C/C++赋值符号“=”的使用上。参见下面代码

std::string s1 = "hello";
std::string s2;
s2 = s1; // OK! 调用成员函数operator= char s11[100] = "hello";
char s22[100];
// s22 = s11; // Error! 数组不能作为左值!
strcpy(s22, s11); // OK! 调用C库函数, 但实际中最好用strncpy来代替strcpy防止溢出

不过从上面代码中也可以看出来C在语法上为字符数组提供了“特权”。正常来说数组可以用初始化列表(即用大括号括起来的若干元素)初始化

int a[] = { ,, };

但是字符数组像这样初始化太麻烦,来体会一下

char s[] = { 'h', 'e', 'l', 'l', 'o' };

所以C可以直接用字符串字面值(string literal)来直接初始化字符数组

char s[] = "hello";

高下立判。(别看现在C语言的语法看起来这么原始,但其实C可是有不少“语法糖”的!)

不过这种做法仅限于初始化,在C/C++中必须得严格区分初始化和赋值,前者是给对象一个初始值,后者是对象已经有一个初始值,然后赋予一个新值。

再看看下面这份代码

std::string s1 = "hello";  // 默认构造
auto s2 = s1; // 拷贝构造
s1 = s2; // 调用成员函数operator = char s11[] = "hello"; // 用字符串字面值来初始化字符数组
// char s22[] = s11; // Error! 数组只能以初始化列表或字符串字面值来初始化
// s22 = s11; // Error! 数组不能作为左值

但是C语言的结构体,对应C++的聚合类,跟普通类有所区别(具体参考C++ Primer 7.5.5),对“=”的支持就好得多

PS:聚合类属于POD(Plain Old Data),之前看《STL源码剖析》时对这个概念也是一知半解,包括后面针对trivial和non-trivial的模板偏特化。

#include <stdio.h>

typedef struct String
{
char s[100];
} String; int main()
{
String s1 = { { "hello" } };
String s2 = s1;
puts(s2.s); // hello
s2.s[1] = '-';
s1 = s2;
puts(s1.s); // h-llo
return 0;
}

代码方面注意main()函数第一行我用了两层{},外层是用初始化列表初始化结构体,内层是用字符串字面值初始化数组。

两处输出的结果和预期的一样,但是C语言没有拷贝构造和运算符重载的概念啊,它是怎么做到的呢?

原因是C的赋值运算符就包含浅复制的特性,也就是说对于结构体而言,赋值操作会把等号右边的变量的每一位给拷贝过去。如果结构体内包含的不是字符数组而是字符指针,那么仅仅是复制了地址,指向的都是内存上同一块地址。

#include <stdio.h>
#include <stdlib.h>
#include <string.h> typedef struct String
{
char* s;
} String; int main()
{
String s1 = { (char*)malloc() };
strncpy(s1.s, "hello", sizeof("hello"));
String s2 = s1;
s2.s[] = '-';
puts(s1.s); // h-llo
free(s1.s);
return ;
}

注意,这里我用了动态分配,如果只是用字符串字面值的话,指针指向的区域(字符串字面值存储在常量区)是不能更改的。在C++11中,只能用const char*指向字符串字面值,因为用char*指向它会有错误的语义,让用户以为这里指向的字符串可以修改。

从上面的例子可以看出,即使在所谓面向过程的C,用结构体这东西把变量包装一下也能起到很好的作用,那么问题来了,回到最初的问题,用C语言实现最初的C++代码一样的功能该怎么去做呢?

于是C的“语法糖”又来了,C的结构体也支持初始化列表,因此可以像下面这样

#include <stdio.h>

typedef struct Object
{
int i;
char s[100];
} Object; int main()
{
Object obj = { 1, "hello" };
printf("%d %s\n", obj.i, obj.s);
return 0;
}

虽然Object占用空间很大(因为要保存字符数组缓存足够大),并且对于真正较大的字符串这个结构体还是无用,只能动态分配。但是就现在要求实现的功能而言,这种做法是可行的,而且更为简洁。(当然,C++用cout会更简洁,不需要调用string::c_str()来取得const char*,但是我并不喜欢C++的I/O,先不说效率,就格式化输出而言远不如printf系列简单,而且iostream默认与cstdio同步,导致速度很慢,关闭同步的话使用iostream和cstdio可能会出问题,二选一我当然选后者,虽然平常简单测试的话混合用用也没什么)

再提一下,之前说过这种类型在C++里属于聚合类,也可以像C一样用初始化列表进行初始化。

到此为止,C的代码直接原封不动用C++的编译方式是可以通过并运行的。

但是毕竟C的结构体不如C++的类方便,比如我现在只想初始化字符串,在C++里可以重载构造函数为Object(const char*)来解决,而C的初始化列表必须对结构体的所有变量依次初始化。对于早期C89标准,GNU提供了这两种方便的初始化方式作为扩展

    Object obj = {
i : ,
s : "hello"
};
printf("%d %s\n", obj.i, obj.s);
    Object obj = {
.i = ,
.s = "hello"
};
printf("%d %s\n", obj.i, obj.s)

厉害了我的C,有了如此便捷且美观的初始化方式,就不需要像C++一样进行多种重载了。类成员变量过多的话,C++要实现灵活的初始化还是挺麻烦的。

假如对包含3个变量(x,y,z)的类,要实现对任意(0或1或2或3)个变量初始化,C++一共要对构造函数重载3^2=9次。而且假如3个变量都是int的话,初始化x和y以及初始化y和z的构造函数就无法区分了。

然并卵,实际应用哪会出现如此蛋疼的需求,就算有,也应该把多个变量个放进一个类里形成聚合类,一个良好的设计几乎不会出现这种顾虑。

然而,这两种方式在C++中均无法通过编译,如下图

C语言(C99标准)在结构体的初始化上与C++的区别

因为我刚才提到了,那是GNU的扩展,并不属于标准C。(虽然gcc编译选项用-std=c89或-ansi也通过了编译?)

但是,较新的C99标准支持了第二种做法,也就是可以写出像下面这样的代码

    struct sockaddr_in srvAddr = {
.sin_family = AF_INET,
.sin_port = htons(PORT), // PORT为自定义的宏,不再赘述
.sin_addr.s_addr = INADDR_ANY
};

而如果是C++裸写socket的话还得额外用个类来封装下(像MFC就提供了CAsyncSocket),或者像这样用旧式C风格的初始化方式

    struct sockaddr_in srvAddr;

    srvAddr.sin_family = AF_INET;
srvAddr.sin_port = htons(PORT);
srvAddr.sin_addr.s_addr = INADDR_ANY;

即使这东西进了C99标准,还是不被C++支持。毕竟C++有构造函数,没必要支持这种初始化方式,而且这里用列表初始化更简单

struct sockaddr_in srvAddr = { AF_INET, htons(PORT), INADDR_ANY };

但这样必须遵从变量在结构体中的顺序,比如AF_INET和htons(PORT)顺序反了的话虽然编译会通过,但是运行就会出问题。而且这种代码可读性不好,不如老老实实用上面那种。

其实写这篇博客主要是因为同学问了我一个类内联合体初始化的问题,当时我认为是不能用字符串字面值来对字符数组赋值,后来发现是初始化,于是隐隐约约觉得不对,后来发现实际上是可以用来初始化的,只不过这种方式C++不支持导致编译一直没通过。(= =b)

C的做法类似这样

#include <stdio.h>

struct Object
{
int i;
char s[100];
union {
int i;
char s[100];
} u;
}; int main()
{
struct Object obj = {
.s = "hello",
.u = { .s = "world" }
};
printf("%s %s\n", obj.s, obj.u.s);
return 0;
}

C++要做到同样功能也可以,因为union也跟struct一样,可以使用构造函数,不过对于类内的union必须显式加析构函数。

这点之前我也纠结了半天,后来翻阅了C++ Primer,发现第19.6章有所提及。引用原文:

“如果union含有类类型的成员,并且该类型自定义了默认构造函数或拷贝控制成员,则编译器将为union合成对应的版本并将其声明为删除的”

Primer也提到了早期C++标准是不允许union内部定义含有默认构造函数或拷贝控制成员的类,C++11标准取消了这个限制但是会把析构函数声明为deleted(说白了就是要你写析构函数,防止内存泄露,我这里使用标准库的类所以不需要在析构函数里添加多余释放内存的代码)

#include <iostream>
#include <string> struct Object
{
int i;
std::string s;
union {
int i;
std::string s; U(const char* _s) : s(_s) { }
~U() { }
} u; Object(const char* s1, const char* s2) : s(s1), u(s2) { }
}; int main()
{
Object obj("hello", "world");
std::cout << obj.s + " " + obj.u.s << std::endl;
return 0;
}

C的union大多时候起到一种隐式类型转换的作用(&取地址,然后对指针类型进行强制转换,然后*解引用)来实现C风格的多态,对于C++来说继承、模板已经可以更优雅地实现这种功能,union的作用也就是节省空间了。

说到底都TM赖“兼容”!