
个人主页道尔柯南❄专栏传送门《C语言》《C》《Linux操作系统》昙花一现却等待了整个白昼蝉鸣一夏却蛰伏了好几个四季。文章目录一、前言二、再探构造函数初始化列表2.1 什么是初始化列表2.2 哪些成员必须使用初始化列表2.3 成员初始化的顺序是什么2.4 成员变量的缺省值是什么2.5 初始化列表小结三、C 类型转换从普通变量到类对象3.1 什么是隐式类型转换3.2 explicit 关键字的作用3.3 多参数构造函数与列表初始化3.4 类对象之间也可以进行转换四、static 成员属于类而不是某个对象4.1 什么是静态成员变量4.2 静态成员函数有什么特点4.3 如何访问静态成员五、友元让指定对象或函数访问私有成员5.1 为什么需要友元5.2 友元函数5.3 友元类六、内部类把紧密相关的类组织在一起6.1 什么是内部类6.2 如何创建内部类对象6.3 内部类与友元有什么关系七、匿名对象不需要名字的临时对象7.1 什么是匿名对象7.2 匿名对象有什么用7.3 为什么 A a(); 容易产生误解八、对象拷贝时的编译器优化8.1 为什么有时候看不到拷贝构造函数执行8.2 连续构造与拷贝构造的优化8.3 返回对象时的优化8.4 如何正确理解对象优化九、本章知识总结十、给 C 初学者的学习建议结尾一、前言在前面的 C 学习中我们已经认识了类和对象了解了成员变量、成员函数、构造函数、析构函数以及拷贝构造函数等基础知识。但是真正掌握 C 面向对象编程还需要进一步理解一些容易混淆的知识构造函数为什么有时需要初始化列表为什么const成员变量和引用成员变量必须在初始化列表中初始化一个类的对象能不能自动转换成另一个类的对象static成员为什么可以被所有对象共享友元和内部类有什么作用匿名对象什么时候销毁为什么有些代码没有显式调用拷贝构造函数却能正常运行本篇文章将围绕这七个核心知识点展开讲解帮助初学者逐步建立完整的 C 类和对象知识体系。二、再探构造函数初始化列表2.1 什么是初始化列表在之前的学习中我们通常这样编写构造函数class Date { public: Date(int year, int month, int day) { _year year; _month month; _day day; } private: int _year; int _month; int _day; };这种写法是在构造函数体内给成员变量赋值。C 还提供了另一种初始化方式初始化列表。class Date { public: Date(int year, int month, int day) : _year(year) , _month(month) , _day(day) { } private: int _year; int _month; int _day; };这里的冒号:表示初始化列表的开始后面通过逗号分隔不同的成员初始化表达式。例如_year(year)表示使用参数year来初始化成员变量_year。初学者可以简单理解为构造函数体内赋值对象构造过程中先完成成员初始化再执行赋值操作。初始化列表直接指定成员在初始化阶段使用什么初始值。需要注意初始化列表不仅是一种代码风格它还关系到对象能否正确构造。2.2 哪些成员必须使用初始化列表在此重点强调三类成员。第一类引用成员变量。引用在定义时必须绑定到一个有效对象不能先声明一个未绑定的引用再在构造函数体内补充绑定。class Test { public: Test(int x) : _ref(x) { } private: int _ref; };下面这种写法是错误的class Test { public: Test(int x) { // _ref x; // 错误引用不能这样初始化 } private: int _ref; };第二类const成员变量。const表示对象初始化后该成员不能通过普通赋值方式修改。class Test { public: Test(int n) : _value(n) { } private: const int _value; };如果试图在构造函数体内写_value n;就会编译报错因为_value必须先完成初始化之后才能进入构造函数体。第三类没有默认构造函数的类类型成员。假设有一个时间类class Time { public: Time(int hour) : _hour(hour) { } private: int _hour; };由于Time只有一个需要传入参数的构造函数没有默认构造函数因此不能直接使用Time的默认构造方式。如果另一个类中包含Time成员就必须在初始化列表中为它提供合适的初始化方式class Date { public: Date(int hour) : _time(hour) { } private: Time _time; };这样_time就可以正确构造。2.3 成员初始化的顺序是什么这是一个非常容易出错的知识点。成员变量的实际初始化顺序取决于它们在类中声明的顺序而不是初始化列表中的书写顺序。看下面的代码#include iostream using namespace std; class A { public: A() : _a2(20) , _a1(10) { } void Print() { cout _a1 _a2 endl; } private: int _a1; int _a2; }; int main() { A a; a.Print(); return 0; }输出结果10 20因为_a1在类中先声明所以它先初始化接着初始化_a2。为了避免代码阅读困难建议始终让初始化列表的书写顺序与成员变量的声明顺序保持一致。2.4 成员变量的缺省值是什么C11 允许在类中声明成员变量时直接提供缺省值。class Date { public: Date() : _month(2) { } private: int _year 2026; int _month 1; int _day 10; };这里的 2026、 1和 10是成员变量的类内初始值并不意味着每个对象创建时都会先完成一次独立的赋值操作。对于没有在初始化列表中显式初始化的成员类内初始值可以提供默认的初始化来源。因此这个例子中_year初始化为2026。_month显式初始化为2。_day使用类内初始值10。如果一个内置类型成员既没有显式初始化也没有类内初始值那么它的值可能是不确定的不应该直接读取。对于类类型成员如果没有显式指定初始化方式则需要依靠相应的默认构造方式如果不存在可用的默认构造函数就可能导致编译错误。2.5 初始化列表小结记住下面四句话每个构造函数都有初始化列表即使没有显式写出来。每个成员变量都需要在初始化阶段完成初始化。引用成员、const成员以及没有默认构造函数的类类型成员通常必须通过初始化列表进行显式初始化。成员的实际初始化顺序由类内声明顺序决定。对于初学者建议优先使用初始化列表编写构造函数。三、C 类型转换从普通变量到类对象3.1 什么是隐式类型转换我们先看一个简单的例子int a 10; double b a;这里把int类型的变量转换成了double类型编译器可以自动完成这种转换。那么普通的整数能不能转换成一个类的对象呢答案是满足条件时可以。#include iostream using namespace std; class A { public: A(int x) : _x(x) { } void Print() const { cout _x endl; } private: int _x; }; int main() { A a 10; a.Print(); return 0; }输出结果10这里A a 10;并不是说整数10本身就是一个A对象而是编译器利用接受一个整数参数的构造函数将整数转换为A类型对象。3.2explicit关键字的作用如果我们不希望构造函数被用于这种隐式类型转换可以添加explicit。class A { public: explicit A(int x) : _x(x) { } private: int _x; };此时A a1(10); // 正确直接初始化 A a2 10; // 错误不允许这种隐式转换初学者可以这样理解不加explicit允许符合条件的隐式转换。加上explicit阻止构造函数用于相应的隐式转换。在实际项目中合理使用explicit可以减少意外类型转换让代码更加清晰。3.3 多参数构造函数与列表初始化C11 还支持使用花括号进行列表初始化。class A { public: A(int x, int y) : _x(x) , _y(y) { } private: int _x; int _y; }; int main() { A a{10, 20}; return 0; }相关例子A aa3 {2, 2};在符合相应构造函数条件时可以利用花括号初始化对象。需要注意带多个参数的构造函数与普通的单参数隐式转换并不完全相同。不要把所有多参数构造函数都理解为可以直接将一个普通数值隐式转换成对象。3.4 类对象之间也可以进行转换除了内置类型转换为类类型之外一个类的对象也可能转换成另一个类的对象。例如class A { public: A(int x) : _x(x) { } int Get() const { return _x; } private: int _x; }; class B { public: B(const A a) : _y(a.Get()) { } private: int _y; }; int main() { A a(100); B b a; return 0; }这里B提供了一个接受A类型对象的构造函数因此可以利用这个构造函数完成从A到B的转换。这说明类型转换不仅发生在int、double这样的内置类型之间也可以通过构造函数发生在不同类类型之间。四、static 成员属于类而不是某个对象4.1 什么是静态成员变量先思考一个问题如果一个类创建了十个对象我们想统计一共创建了多少个对象应该怎么做如果把计数器定义成普通成员变量每个对象都会拥有自己的计数器无法直接实现全体对象共享一个计数值的目的。这时就可以使用static。class A { public: A() { _count; } private: static int _count; };这里的_count是静态成员变量。它有两个重要特点它由整个类共享而不是每个对象单独拥有一份。它不存储在每个普通对象的独立成员数据中。4.2 静态成员函数有什么特点静态成员函数同样使用static声明class A { public: static void Print() { cout Hello C endl; } };调用时可以直接使用类名A::Print();静态成员函数最重要的特点是没有this指针。普通成员函数通过this指针访问当前对象的成员而静态成员函数不属于某一个具体对象因此没有当前对象的this指针。这意味着静态成员函数可以直接访问静态成员但不能直接访问非静态成员。例如class A { public: static void Print() { cout _count endl; // cout _value endl; // 错误 } private: static int _count; int _value 10; }; int A::_count 0;原因很简单_count属于类可以直接访问_value属于某个具体对象而静态函数没有指定当前对象。4.3 如何访问静态成员常见方式有两种A::GetCount();以及A a; a.GetCount();虽然两种方式在允许的情况下都能调用静态成员函数但更推荐使用类名::成员名的形式因为它更清晰地表达了静态成员属于类。另外静态成员仍然受public、protected和private访问权限限制。声明为private的静态成员不能被外部代码随意访问。五、友元让指定对象或函数访问私有成员5.1 为什么需要友元C 面向对象编程强调封装。例如class A { private: int _value 100; };由于_value是私有成员外部普通函数不能直接访问它。但在某些场景中我们可能确实需要让某个特定函数或者另一个类访问这些私有数据。这时可以使用friend关键字。友元可以分为两类友元函数。友元类。需要注意友元会开放原本受保护的访问边界因此应当谨慎使用。5.2 友元函数看下面的代码#include iostream using namespace std; class A { friend void Print(const A a); private: int _value 100; }; void Print(const A a) { cout a._value endl; } int main() { A a; Print(a); return 0; }输出结果100普通函数Print原本不能访问_value但是由于它被声明为A的友元函数因此可以访问A的私有成员。这里需要区分友元函数不是类的成员函数。它只是获得了访问权限并不会因此获得this指针。另外友元函数可以在类定义中的不同访问区域声明其友元身份不受普通访问限定符位置的限制。5.3 友元类如果希望一个类中的成员函数都能够访问另一个类的私有成员可以考虑使用友元类。#include iostream using namespace std; class A { friend class B; private: int _value 100; }; class B { public: void Print(const A a) { cout a._value endl; } }; int main() { A a; B b; b.Print(a); return 0; }输出结果100由于B是A的友元类因此B的成员函数可以访问A的私有成员。但是友元关系需要特别注意三个原则单向性如果B是A的友元不代表A也是B的友元。不传递如果B是A的友元C是B的友元也不代表C是A的友元。不自动继承友元关系不会因为类之间的继承关系而自动传递。友元能够提高某些操作的便利性但过度使用会增加类之间的耦合度降低封装性。因此在能够通过公有成员函数合理解决问题时应优先考虑普通接口。六、内部类把紧密相关的类组织在一起6.1 什么是内部类如果一个类定义在另一个类的内部就可以称为内部类。例如class A { public: class B { public: void Print() { } }; };这里A是外部类。B是内部类。定义内部类的主要意义是表达两个类之间紧密的逻辑关系。例如一个游戏角色类中可能包含专门负责处理角色状态的辅助类如果这个辅助类主要服务于角色类就可以考虑将其设计为内部类。6.2 如何创建内部类对象内部类是一个独立的类型不意味着外部类对象内部一定包含一个内部类对象。在上面的代码中可以这样创建B对象A::B b;这里的A::B表示B是A作用域中的类型。因此sizeof(A)并不会因为类中声明了一个内部类B就自动把B的成员空间计算进去。只有当外部类中实际声明了某个B类型的非静态成员时这个成员才会占据相应的对象空间。6.3 内部类与友元有什么关系C 中内部类的成员函数可以访问外部类的私有成员。例如#include iostream using namespace std; class A { private: int _value 100; public: class B { public: void Print(const A a) { cout a._value endl; } }; }; int main() { A a; A::B b; b.Print(a); return 0; }输出结果100内部类B可以访问外部类A的私有成员这是 C 内部类规则的一个重要特点。不过反过来不能简单认为外部类也因此自动获得访问内部类所有私有成员的权限。内部类与友元一样涉及明确的访问权限规则不能只凭名称上的包含关系来推断访问权限。七、匿名对象不需要名字的临时对象7.1 什么是匿名对象之前创建对象时我们一般会给对象起一个名字A a(10);这里的a就是对象名。C 还允许我们直接创建一个不命名的临时对象A(10);这种写法可以创建一个匿名临时对象。示例为#include iostream using namespace std; class A { public: A(int x 0) : _x(x) { cout A(int x) endl; } ~A() { cout ~A() endl; } private: int _x; }; int main() { A a1(10); A(20); return 0; }这里创建了一个有名对象a1也创建了一个匿名临时对象A(20)。在这个完整表达式结束时匿名临时对象的生命周期结束因此会调用析构函数。程序结束时局部对象a1也会被销毁。7.2 匿名对象有什么用匿名对象适用于一些只需要临时使用对象的场景。例如class Solution { public: int Sum(int n) { return n; } }; int main() { int result Solution().Sum(10); return 0; }这里Solution().Sum(10)创建一个临时的Solution对象并通过它调用Sum函数。由于没有必要保存这个对象的名字因此可以直接使用匿名对象。7.3 为什么A a();容易产生误解初学者经常会写A a();然后认为自己创建了一个默认对象。实际上在常见的这种语境下编译器会将它解释为函数声明而不是对象定义。如果想创建一个对象可以根据构造函数的定义写成A a;或者A a(10);也可以在适当的场景中使用A();不过空参数的匿名对象通常没有必要单独写出来因为创建后很快就会销毁。八、对象拷贝时的编译器优化8.1 为什么有时候看不到拷贝构造函数执行先回忆一下拷贝构造函数class A { public: A(int x 0) : _x(x) { } A(const A other) : _x(other._x) { cout Copy constructor endl; } private: int _x; };当我们使用一个对象初始化另一个对象时可能会调用拷贝构造函数A a1(10); A a2(a1);但是C 编译器有时会对对象构造过程进行优化减少不必要的临时对象和拷贝操作。这种优化的目的是在不改变程序语义的前提下提高效率。8.2 连续构造与拷贝构造的优化例如void Func(A a) { } int main() { Func(10); return 0; }这里的10是一个整数而Func接受的是A类型参数。如果A有可用的单参数构造函数编译器可以利用它完成类型转换。从概念上看这涉及把整数转换为A类型对象再将对象传递给函数。对于满足相应规则的表达式编译器可以减少中间的构造或拷贝操作。类似地Func(A(20));也可能通过编译器优化减少不必要的拷贝。不过初学者要注意编译器优化的具体表现会受到 C 标准版本、表达式形式和编译器实现的影响。不应仅凭某一次运行的打印结果就断定所有编译器都会产生完全相同的构造与析构日志。8.3 返回对象时的优化再看一个例子A Create() { A a(10); return a; } int main() { A result Create(); return 0; }在Create()中局部对象a被作为返回值返回。从直观角度看我们可能会认为创建局部对象a。拷贝a产生返回临时对象。将返回临时对象拷贝给result。销毁临时对象。销毁局部对象。但实际情况并不一定如此。编译器可以对部分对象构造过程进行优化减少不必要的拷贝构造。在符合 C17 及后续标准规定的特定场景下某些临时对象的消除甚至是标准要求的而不是单纯由编译器自由选择。传统示例使用支持相关选项的 GCC 编译器时可以尝试g test.cpp -fno-elide-constructors该选项可以关闭部分允许省略的构造操作优化便于观察构造函数和拷贝构造函数的调用过程。但要注意这个选项不能撤销 C17 标准要求的强制拷贝消除。8.4 如何正确理解对象优化建议初学者分清三个概念构造函数负责创建并初始化对象。拷贝构造函数使用已有对象初始化新对象。赋值运算符给已经存在的对象赋值。例如A a1(10); A a2 a1; // 拷贝构造 A a3(20); a3 a1; // 如果提供了合适的赋值运算符则执行赋值虽然在两处都出现了但两种操作并不相同。理解这一区别再结合构造与拷贝消除规则就更容易分析复杂代码的执行过程。九、本章知识总结通过本篇文章我们学习了 C 类和对象中的七个重要知识点。知识点核心内容学习重点初始化列表在初始化阶段为成员提供初始值引用、const、类类型成员和初始化顺序类型转换利用构造函数完成符合条件的类型转换explicit、列表初始化静态成员由整个类共享类外定义、静态函数没有this指针友元允许指定函数或类访问私有成员单向性、不传递性、谨慎使用内部类在类内部定义另一个类型作用域、访问权限、对象空间匿名对象创建不具备普通对象名的临时对象生命周期、临时使用场景拷贝优化减少不必要的对象构造和拷贝拷贝构造、返回值优化、标准规则十、给 C 初学者的学习建议学习这些知识时不建议只记忆概念和结论而应该多做代码实验。可以按照下面的顺序练习编写一个包含普通成员、引用成员和const成员的类练习初始化列表。给一个类添加单参数构造函数分别测试普通初始化与explicit。编写一个带静态计数器的类观察创建、拷贝和销毁对象时计数器的变化。分别实现友元函数、友元类和内部类理解访问权限。编写匿名对象示例观察构造函数和析构函数的输出。给类添加拷贝构造函数在不同表达式和编译选项下观察拷贝次数。C 的很多知识点只有真正编写并运行代码才能从“知道语法”逐渐提升到“理解原理”。结语类和对象是 C 面向对象编程的核心。初始化列表帮助我们正确初始化对象静态成员实现类级别的数据共享友元与内部类提供更灵活的访问与组织方式而匿名对象和拷贝优化则帮助我们进一步理解对象生命周期与程序效率。掌握这些内容后我们就能更有条理地分析 C 程序中的对象创建、初始化、转换、拷贝和销毁过程为后续学习继承、多态、智能指针以及现代 C 编程打下基础。结尾uu们本文的内容到这里就全部结束了道尔在这里再次感谢您的阅读道尔柯南C/C Linux 底层探索者 | 一个正在努力学习的技术博主【关注】跟随我一起深耕技术领域见证每一次成长。❤️【点赞】让优质内容被更多人看见让知识传递更有力量。⭐【收藏】把核心知识点存好在需要时随时查、随时用。【评论】分享你的经验或疑问评论区一起交流避坑不要忘记给博主“一键四连”哦“今日目标达成”结语希望对学习C相关内容的uu有所帮助不要忘记给博主“一键四连”哦