沈阳做企业网站,搜索引擎优化seo专员招聘,为企业做贡献,wordpress调用单页面跳转大家好#xff0c;我是小康#xff0c;今天我们来聊下如何快速学习 C 语言。
本篇文章适合于有 C 语言编程基础的小伙伴们#xff0c;如果还没有学习过 C#xff0c;请看这篇文章先入个门#xff1a;C语言快速入门
引言#xff1a;
C#xff0c;作为一门集面向过程和…大家好我是小康今天我们来聊下如何快速学习 C 语言。
本篇文章适合于有 C 语言编程基础的小伙伴们如果还没有学习过 C请看这篇文章先入个门C语言快速入门
引言
C作为一门集面向过程和面向对象编程于一体的强大语言既保留了 C 语言的高效性又引入了类、继承、多态、模板等现代编程概念是学习计算机编程不可或缺的一环。本文旨在为初学者提供一个清晰的 C 学习路径帮助你快速入门并掌握这门语言。
大家可以先浏览下本篇文章要讲解的 C 知识图谱 C的基础语法我就不讲解了包括变量和常量的定义、标识符和关键字、语句等这些和 C 的一样如果你还没有学习过 C 语言可以看我之前的文章「如何快速学习 C 语言 」
数据类型
基本数据类型
C的基本数据类型包括 字符型、整型、浮点型和布尔型。
字符型 (char)用于存储单个字符。
char letter A;整型 (int, short, long, long long)用于存储整数, 以及它们的 unsigned 变体(unsigned int, unsigned short,unsigned long,unsigned long long)。
int age 30;
long var 1000000;unsigned int bigInt 1000000;浮点型 (float, double)用于存储小数。
float temperature 36.6;
double distance 384400.0; // 从地球到月亮的距离单位千米布尔型 (bool)用于存储真true或假false。
bool isRainy false;枚举类型
枚举类型允许定义一个变量它可以在几个预定义的值之间进行选择。
enum Color { RED, GREEN, BLUE };
Color my_color RED;复合数据类型
复合数据类型也称为复杂数据类型允许你将多个不同类型的数据项组合成一个单一的实体。这种类型的典型代表是结构体struct、共用体union和类class。
结构体 (struct)
结构体允许将多个不同类型的数据项组合成一个单一的复合类型。
// 定义结构体类型
struct Person {std::string name;int age;
};// 定义结构体类型变量并初始化
Person person {Alice, 30};联合体union
联合体是一个用于优化内存使用的特殊数据类型允许在同一内存位置存储不同的数据类型但任一时刻只能使用其中一个成员。联合体变量使用关键字 union 来定义。
union Data {int i;float f;char str[20];
};
union Data data;类 (class)
类是C的核心是支持面向对象编程的基础。
// 定义 Book 类
class Book {
public:std::string title;std::string author;void read() {std::cout Reading title by author std::endl;}
};
// 定义 book 对象
Book book {The C Programming Language, Bjarne Stroustrup};
// 调用 book 对象的 read 方法
book.read();看不懂代码没关系这里只需要了解 类 是 C 的一种特有数据类型。关于类的讲解下文会提及。
派生数据类型
派生数据类型是通过对已有的数据类型基本类型、复合类型进行某种形式的“扩展”或“派生”而得到的。典型的派生数据类型包括数组和指针。
数组
数组用来存储固定大小的相同类型元素序列。
int numbers[5] {1, 2, 3, 4, 5};指针
指针用来存储变量的内存地址。
int var 10;
int* ptr var;
std::cout Value of var: *ptr std::endl;这里我只简单提下。在上一篇文章如何快速学习 C 语言中关于数组和指针有过详细的讲解不太了解的可以去看那片文章。C 的数组和指针和C的用法一样。只不过 C 多了一种比较特殊的类型-引用。
引用
C中的引用是一种给已存在的变量起一个新名字或别名的机制。一旦一个引用被初始化为指向一个变量它就一直指向那个变量你对引用所做的任何操作都会影响到原始变量。
引用的基本用法
引用在定义时必须被初始化并且一旦被绑定到一个变量上就不能再绑定到另一个变量上。引用的语法是在变量类型后面加上 符号。
int a 10;
int refA a; // refA是变量a的引用引用的特性
引用必须在定义时被初始化并且一旦被初始化绑定到一个变量就不能再指向其他变量。引用不占用任何内存空间引用只是变量的一个别名。不存在null引用。引用必须连接到一块合法的内存。
引用的用途
引用主要用于以下几个方面
1. 函数参数传递通过传递引用给函数可以让函数直接修改外部变量的值而不是拷贝其值。这样做可以提高效率尤其是对于大型对象。
// 通过使用引用作为函数参数可以使得函数能够修改调用者提供的参数。
void increment(int value) {value 1; // 直接修改传入的参数
}int main() {int x 5;increment(x); // x被修改为6std::cout x after increment: x std::endl;return 0;
}在 C 中函数参数传递时参数是类对象比较常见。
2. 函数返回值函数可以返回一个引用从而允许对函数返回值直接赋值。这在操作重载运算符时尤其有用。
#include iostream
int myNumber 10; // 全局变量// 返回全局变量myNumber的引用
int getMyNumberRef() {return myNumber;
}int main() {std::cout Original myNumber: myNumber std::endl; // 输出: 10// 通过函数返回的引用直接修改myNumber的值getMyNumberRef() 20;std::cout Modified myNumber: myNumber std::endl; // 输出: 20return 0;
}
对于拷贝代价较大的对象比如大型的类实例通过引用传递或返回可以避免拷贝提高程序效率。
函数
C 中的函数是一组一起执行一个任务的语句。函数允许你定义一次代码块并多次调用它这有助于代码的重用和模块化。
函数定义
一个 C 函数定义包括以下几个主要部分
返回类型函数可以返回一个值。返回类型是函数返回值的数据类型。如果函数不返回值则使用void类型。函数名称标识函数的唯一名称。参数列表括号内的参数用于从函数调用者向函数传递信息。如果函数不接受任何参数则括号为空。函数体大括号内的一系列语句定义了函数的执行任务。
// 函数定义示例
int add(int a, int b) {return a b;
}函数声明函数原型
为了在定义函数之前调用函数你需要在调用点之前声明函数原型。函数声明或称为函数原型仅需要指定函数返回类型、函数名和参数类型不需要函数体。
int add(int a, int b); // 函数声明函数调用
定义函数后你可以通过提供函数名和所需的参数如果有的话来调用函数。
函数调用方式
函数名(参数1参数2…)
int main() {int result add(5, 3); // 函数调用std::cout Result: result std::endl; // 输出Result: 8return 0;
}参数传递
C支持几种参数传递方式
按值传递调用函数时实参的值被拷贝给形参。在函数内对形参的修改不会影响实参。按引用传递允许函数修改调用者的变量。这通过将形参定义为引用类型实现。按指针传递意味着将变量地址作为参数传递给函数函数通过这个指针直接访问和修改原始变量的值。
代码示例
#include iostream
// 按值传递
void byValue(int value) {value 10; // 只修改形参的值对实参无影响
}
// 按引用传递
void byReference(int value) {value 20; // 修改了实参的值
}
// 按指针传递
void byPointer(int* value) {*value 30; // 通过解引用修改了实参的值
}int main() {int a 1, b 1, c 1;byValue(a);std::cout After byValue: a std::endl; // 输出 1byReference(b);std::cout After byReference: b std::endl; // 输出 20 byPointer(c);std::cout After byPointer: c std::endl; // 输出 30return 0;
}函数重载
C中的函数重载Function Overloading是指允许在同一作用域内声明多个具有相同名称的函数只要它们的参数列表参数的类型、数量或顺序不同即可。编译器根据函数调用时提供的参数类型和数量来决定具体调用哪个函数。
代码示例
// 以下两个 print 函数构成重载
void print(int i) {std::cout Printing int: i std::endl;
}
void print(double f) {std::cout Printing float: f std::endl;
}int main(){print(10);print(10.5);return 0;
}特殊函数
成员函数
在 C 的类中定义的函数称为成员函数Member Functions。成员函数可以访问类的私有private、保护protected和公有public成员。
代码示例
class MyClass {
public:void myFunction() {// 成员函数实现}
};常量成员函数
常量成员函数是 C 中的一种特殊的成员函数它保证在函数执行过程中不会修改对象的任何成员变量。这种函数通过在成员函数声明的末尾添加 const 关键字来定义。常量成员函数可以被任何类型的对象调用包括常量对象。
在类的实现中常量成员函数对类内部的状态成员变量只能进行只读操作不能进行修改。这为类的使用提供了额外的安全保证确保了不会意外改变对象状态的函数逻辑。
代码示例
// 定义 MyClass 类
class MyClass {
private:int value;
public:MyClass(int v) : value(v) {} // 构造函数初始化value// 常量成员函数声明使用const关键字int getValue() const {return value; // 这里只是返回成员变量的值不会修改它}// 尝试在常量成员函数中修改成员变量将导致编译错误void tryToModify() const {// value 100; // 错误不能在常量成员函数中修改成员变量}
};int main() {MyClass obj(42);std::cout The value is: obj.getValue() std::endl; // 输出The value is: 42const MyClass constObj(55);std::cout The value is: constObj.getValue() std::endl; // 输出The value is: 55// constObj.tryToModify(); // 错误不能在常量对象上调用非常量成员函数return 0;
}代码看不懂不要紧这里只要了解常量成员函数的基本概念以及如何声明即可。看完下文类和对象的讲解再回过头来看代码就可以理解。
面向对象编程
C的面向对象编程OOP是一种编程范式它使用“对象”来设计软件。对象可以包含数据称为属性或成员变量和代码称为方法或成员函数。C的 OOP 建立在几个核心概念之上类、封装、继承、多态。让我们一一详细讲解这些知识点。
类和对象
类的定义
类是创建对象的蓝图。它定义了对象的属性成员变量和行为成员函数或方法在C中使用关键字class 来定义类。
// 定义 MyClass 类
class MyClass {}成员的访问权限
在C中类的成员包括变量和函数可以具有三种不同的访问权限public、private和protected。这些访问权限控制了类外部的代码对类成员的访问级别从而实现了封装和数据隐藏。 public公有成员可以被任何其他代码访问无论是类的内部还是外部。如果类的成员声明为public那么在类的实例化对象外部也可以直接访问这些成员。 private私有成员只能被该类的成员函数、友元函数和该类的其他实例访问。如果类的成员声明为private那么这些成员只能在类的内部被访问。这是默认的访问级别如果没有指定访问权限则成员默认为private。 protected受保护成员可以被该类的成员函数、友元函数、该类的派生类中的成员访问。如果类的成员声明为protected那么这些成员既可以在类的内部被访问也可以在派生类中被访问但不能直接通过类的实例在类的外部被访问。
class MyClass {
public:int publicVar; // 公有成员变量任何地方都可访问
protected:int protectedVar; // 受保护成员变量类内部和派生类可访问
private:int privateVar; // 私有成员变量仅类内部可访问void privateMethod() {// 私有成员函数仅类内部可访问}
public:void publicMethod() {// 公有成员函数任何地方都可访问privateVar 0; // 可以访问私有成员privateMethod(); // 可以调用私有成员函数}
};成员访问权限有什么用
封装通过将成员设为私有或受保护类可以隐藏其实现细节仅通过公有接口与外界交互。这样做可以在不影响外部代码的情况下自由修改类的内部实现。维护性限制对成员的访问可以减少因错误使用类成员而产生的bug使得代码更加可维护。扩展性合理使用访问权限可以在不破坏原有类的基础上进行扩展增加新的功能。
成员变量和成员函数
类中定义的变量称为成员变量类中定义的函数称为成员函数。它们定义了类的属性和行为。
成员变量初始化有以下两种方式
1. 构造函数初始化列表使用构造函数的初始化列表直接初始化成员变量这是最常用且推荐的初始化成员变量的方式特别是对于常量成员和引用成员。
class Example {
private:int data;
public:Example(int d) : data(d) {} // 构造函数初始化列表
};2. 在构造函数体内赋值在函数体内对成员进行初始化。
class Example {
private:int data;
public:Example(int d) {data d; // 在构造函数体内赋值}
};构造函数和析构函数
构造函数构造函数的名称与类名相同可以有参数也可以重载即定义多个构造函数每个构造函数有不同的参数列表。如果你不提供任何构造函数C编译器会自动生成一个默认的无参构造函数。
构造函数在创建对象时自动调用用于初始化对象。
class Car {
public:Car() { // 默认构造函数cout Car object created. endl;}Car(string brand) { // 带有参数的构造函数cout brand car object created. endl;}
};析构函数析构函数的名称是类名前加上波浪符号~它不能带参数因此一个类只能有一个析构函数。析构函数用于执行对象销毁前的清理工作比如释放分配的资源等。
class Car {
public:~Car() { // 析构函数cout Car object destroyed. endl;}
};通过合理定义和使用构造函数和析构函数我们可以确保对象在创建和销毁时维持合理的状态以及有效地管理资源。
一个简单的 Car 类定义示例
该 Car 类包含构造函数和析构函数成员变量和成员函数等基本成员。
// 定义 Car 类
class Car {
public:string brand; // 汽车的品牌// 构造函数使用初始化列表来初始化成员变量。Car(string b) : brand(b) {cout brand car is created. endl;}// 析构函数~Car() {cout brand car is destroyed. endl;}// 成员函数void drive() {cout Driving brand endl;}
};
int main() {Car myCar(Ford); // 创建一个Car对象品牌为FordmyCar.drive(); // 调用drive成员函数return 0;
}对象的创建
对象是类的实例。通过类我们可以创建对象并使用其属性和方法。对象可以通过成员访问运算符.访问其成员变量和成员函数。
Car myCar(Ford); // 福特汽车
myCar.drive(); // 访问 myCar 对象的成员方法this指针
在C中this指针是一个特殊的指针它指向当前对象。每个非静态成员函数包括构造函数、析构函数以及其他成员函数都有一个this指针作为其隐式参数这使得成员函数能够访问调用它的对象的成员。this指针在成员函数内部使用特别是在需要引用调用函数的当前对象时。
当我们在类的成员函数中需要引用对象本身时就会用到this指针。这在以下几种情况中尤其有用
当参数名称与成员变量名称相同时用以区分成员变量和参数。在实现链式调用时返回对象的引用。当需要返回对象本身的指针时。
示例代码
1. 区分成员变量和参数
class Box {
public:int width;Box(int width) {// 使用this指针区分成员变量和构造函数参数this-width width;}void displayWidth() {cout Width: width endl; // 直接访问width实际上是this-width}
};在这个例子中构造函数的参数width与类的成员变量width同名。通过使用this-width我们明确指出了左边的width是对象的成员变量而不是参数。
2. 实现链式调用
链式调用是一种编程风格通过在成员函数末尾返回对象本身可以连续调用多个成员函数。
示例代码
class Box {
private:int width;
public:Box setWidth(int width) {this-width width;return *this; // 返回当前对象的引用}void displayWidth() {cout Width: width endl;}
};int main() {Box box;box.setWidth(10).displayWidth(); // 链式调用return 0;
}在setWidth函数中返回*this允许链式调用即连续调用对象的成员函数。
3. 返回对象本身的指针
有时候我们可能需要在成员函数中返回指向当前对象的指针。
示例代码
class Box {
public:Box* getPointer() {return this; // 返回指向当前对象的指针}
};在getPointer函数中通过返回this我们得到了一个指向当前对象的指针。
this指针是 C 中一个强大的工具它提供了一个自引用的机制。通过 this 指针类的成员函数可以访问调用它们的对象的其他成员。理解 this 指针对于深入学习C面向对象编程非常重要。
而理解 this 指针关键是要了解它的底层原理
在C中this指针的底层实现其实非常直观。当一个非静态成员函数被调用时编译器隐式地将当前对象的地址作为一个参数传递给函数。这个隐式参数就是this指针。因此每个非静态成员函数在内部都有一个名为this的额外参数指向调用该函数的对象。
底层实现 对于一个类ClassType中的非静态成员函数memberFunction调用形式object.memberFunction(args...),实际上在底层被编译器处理为ClassType::memberFunction(object, args...)其中object就是this指针。 因此即使你在成员函数定义中没有显式地看到this参数编译器仍然按照每个非静态成员函数都有一个类型为ClassType*的this指针作为其第一个参数的方式来处理。
考虑以下类定义
class MyClass {
public:int a;void myFunction() {std::cout Value of a: this-a std::endl;}
};当你创建一个MyClass对象并调用其成员函数myFunction时
MyClass obj;
obj.a 10;
obj.myFunction();在调用 obj.myFunction() 时实际上编译器在底层将其转换为类似以下形式的调用这是一种简化的表达实际转换会依赖于具体的编译器
MyClass::myFunction(obj);这里obj 是对象 obj 的地址它被隐式地作为 this 指针传递给 myFunction。所以在 myFunction 内部当你访问 this-a 时实际上就是通过 obj 的地址来访问它的成员变量 a。
封装
封装在 C 面向对象编程中是一种将数据属性和行为方法捆绑在一起的机制同时对外隐藏内部实现的细节仅通过定义好的接口与外界交互。
简单来说:封装实质上是关于数据隐藏和接口暴露的。在定义一个类时你会将某些数据成员标记为 private这意味着它们只能被类的内部成员函数访问对类的使用者来说这些细节被隐藏了。然而你也会提供public的成员函数作为操作这些数据的接口这样类的使用者可以在不知道内部实现细节的情况下通过这些接口来操作对象。
封装的实现
在C中封装通过类实现类中可以定义三种类型的成员public公有成员、private私有成员和protected受保护成员。这些访问修饰符定义了成员的访问范围
private 成员只能由同一类的成员函数访问。public 成员可以由任何可以访问类对象的代码访问。protected 成员可以被基类和派生类中的成员函数访问。
通过精心设计公有接口类的设计者可以控制外部代码对内部数据的访问方式保护对象的状态不被非法操作破坏。
封装的优势
数据安全通过隐藏内部实现细节减少了外部对内部数据的直接访问降低了数据被误用或误修改的风险。接口清晰用户只需关注类提供的公有接口不必深究类的内部实现使得类更加易于使用和理解。易于维护和扩展类的内部实现可以自由修改只要公有接口保持不变就不会影响到使用该类的代码提高了代码的可维护性和扩展性。
一个体现 C 封装的类的实现
#include iostream
#include string
using namespace std;class Person {
private:string name; // 私有成员变量存储人的姓名int age; // 私有成员变量存储人的年龄
public:// 构造函数初始化姓名和年龄使用初始化列表来初始化成员变量。Person(string n, int a) : name(n), age(a) {}// 公有成员函数设置姓名void setName(string n) {name n;}// 公有成员函数获取姓名string getName() const {return name;}// 公有成员函数设置年龄void setAge(int a) {if(a 0) { // 确保年龄是非负数age a;}}// 公有成员函数获取年龄int getAge() const {return age;}// 成员函数打印Person信息void printInfo() const {cout Name: name , Age: age endl;}
};int main() {Person person(John Doe, 30); // 创建Person对象person.printInfo(); // 打印信息// 尝试修改Person的姓名和年龄person.setName(Jane Doe);person.setAge(25);// 再次打印修改后的信息person.printInfo(); // 打印信息return 0;
}继承
继承的定义
继承允许新的类派生类继承现有类基类的属性和方法。它支持代码重用并建立了类之间的层次关系。
继承的分类
单一继承
在单一继承中一个派生类只继承自一个基类。这意味着派生类包含了基类的所有属性和方法同时还可以添加自己的属性和方法或者重写基类的方法。
定义方式
class BaseClass {// 基类的成员
};class DerivedClass : public BaseClass {// 派生类的成员
};这里DerivedClass 是通过单一继承从 BaseClass派生而来的。
示例
// 基类
class Animal {
public:void eat() {cout Eating. endl;}
};
// 派生类
class Dog : public Animal {
public:void bark() {cout Barking. endl;}
};
int main() {Dog myDog;myDog.eat(); // 调用基类的方法myDog.bark(); // 调用派生类的方法return 0;
}多重继承
多重继承允许一个派生类同时从多个基类继承属性和方法。这种方式增加了灵活性但也可能引入复杂性例如需要处理潜在的命名冲突以及著名的“菱形问题”。
定义方式
class BaseClass1 {// 基类1的成员
};
class BaseClass2 {// 基类2的成员
};
class DerivedClass : public BaseClass1, public BaseClass2 {// 派生类的成员
};在这个例子中DerivedClass 同时从 BaseClass1 和BaseClass2 继承成为它们的派生类。
示例
// 第一个基类
class Animal {
public:void eat() {cout Eating. endl;}
};
// 第二个基类
class Bird {
public:void fly() {cout Flying. endl;}
};// 派生类(麻雀)继承自Animal和Bird
class Sparrow : public Animal, public Bird {
public://发出声音模拟麻雀叫声void chirp() {cout Chirping. endl;}
};int main() {Sparrow mySparrow;mySparrow.eat(); // 调用Animal基类的方法mySparrow.fly(); // 调用Bird基类的方法mySparrow.chirp(); // 调用派生类的方法return 0;
}多重继承允许一个派生类同时继承自多个基类。这是C提供的一种强大功能它可以让派生类继承并实现多个基类定义的接口和属性。然而在使用多重继承时我们可能会遇到一种特殊情况菱形继承也称为钻石继承问题。
菱形继承
假设有这样一个场景我们有一个基类A然后有两个类B和C分别继承自A最后有一个类D同时继承自B和C。这样构成的继承结构形状像一个菱形因此称为菱形继承。如下图 A/ \B C\ /D// 对应的代码示例
class A {
public:int value;
};
class B : public A {// ...
};
class C : public A {// ...
};
class D : public B, public C {// D通过B和C继承了A可能会导致A的成员在D中存在多份拷贝
};菱形继承引入的问题是D通过B和C继承了两份A的成员这导致了数据冗余和不一致性的风险。特别是当试图访问从A继承来的成员时编译器会因为不知道应该通过B还是C的路径去访问而产生歧义。
解决菱形继承问题
C中通过引入虚继承来解决菱形继承问题。在菱形继承的结构中将B和C对A的继承声明为虚继承使用virtual关键字可以确保D中只有一份A的成员副本。
这样无论是通过B还是C访问到的都是同一份来自A的成员解决了成员访问歧义的问题并且保证了数据的一致性。
虚继承的声明方式
通过在派生类中使用 virtual 关键字进行继承。
使用虚继承来解决菱形继承问题示例
class A {
public:int value;
};// B和C虚继承A
class B : virtual public A {};
class C : virtual public A {};// D继承自B和C
class D : public B, public C {};在这个例子中通过将 B 和 C 对 A 的继承声明为虚继承我们确保了在 D 中只有一份来自 A 的成员 value无论是通过 B 还是 C 的路径访问 value访问到的都是相同的成员。
友元
在C中友元Friend是一个允许某些外部函数或类访问另一个类的私有private和保护protected成员的特性。友元关系不受类之间的公有public、私有private和保护protected访问控制的约束这使得某些特定的函数或类可以直接访问类的内部成员。
友元机制可以增强程序的灵活性但同时也可能破坏对象的封装性。
友元可以是
友元函数友元类友元成员函数
友元函数
友元函数在C中是一种特殊的函数它虽然不是类的成员函数但能够访问类的私有private和保护protected成员。这允许全局函数访问类的私有成员。
友元函数的定义包含两个主要步骤 在类内声明友元函数你需要在类定义内部使用 friend 关键字声明该函数为友元这告诉编译器这个特定的函数可以访问类的私有和保护成员。 定义友元函数友元函数的定义与普通函数相同但需要注意的是友元函数本身不是类的成员函数因此它不能通过对象或指针来调用而是像普通函数那样直接调用。
代码示例
class MyClass {
private:int value;
public:MyClass(int val) : value(val) {} // 构造函数初始化value// 声明友元函数friend void friendFunction(MyClass obj);
};
// 定义友元函数
void friendFunction(MyClass obj) {// 友元函数可以访问MyClass的私有成员valuestd::cout Accessing private member value: obj.value std::endl;
}int main() {MyClass myObj(100);// 调用友元函数并访问MyClass对象的私有数据friendFunction(myObj); // 输出: Accessing private member value: 100return 0;
}
友元类
当一个类被声明为另一个类的友元时这个友元类的所有成员函数都可以访问另一个类的私有和保护成员。
示例代码
class Box {double width;
public:Box(double wid) : width(wid) {}friend class Printer; // 声明Printer为友元类
};class Printer {
public:void printWidth(Box box) {cout Width of box : box.width endl;}
};int main() {Box box(10.0);Printer printer;printer.printWidth(box);return 0;
}这个例子中Printer 类是 Box 类的友元因此 Printer 的成员函数 printWidth可以访问 Box 的私有成员 width。
友元成员函数
一个类的成员函数可以被声明为另一个类的友元。
示例代码
class Box {double width;
public:Box(double wid) : width(wid) {}friend void Printer::printWidth(Box box); // 前向声明
};class Printer {
public:void printWidth(Box box) {cout Width of box : box.width endl;}
};int main() {Box box(10.0);Printer printer;printer.printWidth(box); // 使用Printer对象打印Box的宽度return 0;
}在这个例子中Printer类的成员函数printWidth是Box类的友元因此它可以访问Box的私有成员width。
注意事项
使用友元时应谨慎因为它破坏了类的封装性。一个设计良好的类应该尽量隐藏其实现细节只通过公共接口与外界交互。友元关系不能被继承。友元关系是单向的即如果类A是类B的友元类B不一定是类A的友元。
运算符重载
运算符重载是 C 中一个非常强大的特性它允许开发者为自定义类型指定运算符操作的行为。这样我们就可以对自定义类型使用标准的C运算符如、-、等。这不仅可以提高代码的直观性还可以使得自定义类型的操作更加自然。
定义
运算符重载允许开发者为自定义类型重新定义运算符的功能。它可以作为成员函数或非成员函数(友元函数)实现但必须至少有一个操作数是用户定义的类型。
使用运算符重载时需要遵循一些规则
不能改变运算符的优先级。不能创造新的运算符。有些运算符不能被重载如.、::、?:和sizeof。其他的内置运算符都是可以重载的比如常见的算术运算符、比较运算符、逻辑运算符等。大多数重载的运算符可以是成员函数也可以是非成员函数但有些必须是成员函数如赋值运算符。
运算符重载的分类
根据运算符作用于的对象运算符重载可以是成员函数或非成员函数。
成员函数运算符重载
当运算符重载作为成员函数时它的第一个操作数隐式地成为了调用它的对象这意味着你不能改变操作数的顺序。这通常用于二元运算符比如加法运算符或一元运算符比如递增运算符。
我们先来看个成员函数运算符重载的例子,以重载运算符为例定义一个 Point 类并为它重载 运算符。
#include iostream
class Point {
public:int x, y;Point(int x 0, int y 0) : x(x), y(y) {}// 重载运算符Point operator(const Point rhs) const {return Point(x rhs.x, y rhs.y);}
};int main() {Point p1(1, 2), p2(3, 4);Point p3 p1 p2;std::cout p3 ( p3.x , p3.y ) std::endl;return 0;
}在这个例子中我们定义了 Point 类的对象可以通过运算符相加返回两点坐标的和。
非成员函数运算符重载
非成员函数运算符重载通常声明为类的友元这样它们就可以访问类的私有成员。这种方式适用于操作符左侧的对象不是重载运算符所在类的实例的情况比如输出流运算符。
重载运算符
接下来我们看下非成员函数运算符重载的例子重载运算符以便能够直接打印 Point 对象。
由于运算符需要操作std::ostream类型的左操作数如std::cout它不能作为成员函数重载而应该是非成员函数(友元函数)。通常我们会将这样的函数声明为友元以便它可以访问类的私有成员。
#include iostream
class Point {friend std::ostream operator(std::ostream os, const Point p);
public:int x, y;Point(int x 0, int y 0) : x(x), y(y) {}
};
// 重载运算符
std::ostream operator(std::ostream os, const Point p) {os ( p.x , p.y );return os;
}int main() {Point p1(1, 2);std::cout p1 std::endl; // 现在可以直接打印Point对象了return 0;
}这里留个问题为什么运算符不能作为成员函数重载而只能是非成员函数
这个问题在我开始学习运算符重载的时候就挺疑惑的不过现在已经搞清楚了接下来我尽可能用易懂的文字及代码示例给大家讲解清楚
在 C 中当你使用如 std::cout object; 的形式进行输出时期望的行为是把object的内容发送到输出流std::cout。为了实现这个行为我们需要重载运算符。但这里的挑战在于std::cout是一个std::ostream类型的对象而object是另一个用户自定义类型的类对象。
成员函数的限制 如果运算符是作为用户自定义类型的一个成员函数来重载它的使用方式将变为object.operator(std::cout);。这意味着从语法上讲你正在尝试向object发送std::cout而不是反过来。这与我们通常使用输出流的直觉相违背因为我们希望std::cout在左边object在右边即std::coutobject。
class MyClass {
public:int value;MyClass(int v) : value(v) {}// 假设尝试将 作为成员函数重载void operator(std::ostream os) {os value;}
};int main() {MyClass obj(10);std::cout obj; // 这是我们想要的使用方式obj std::cout; // 如果是成员函数实际调用将会是这样这显然不符合我们的预期return 0;
}为什么用非成员函数?
为了让std::cout object;按预期工作我们需要把运算符重载为非成员函数这样它就可以接受两个参数左边的std::ostream对象和右边的用户自定义类型对象。这种方式符合我们直观的使用习惯。
使用友元函数
此外由于重载的运算符通常需要访问用户自定义类型对象的内部数据可能包括私有成员我们一般会把这个重载函数声明为友元函数。这样即使是非成员函数它也能访问类的私有或受保护成员从而可以输出对象的内部状态。
class MyClass {
public:int value;MyClass(int val) : value(val) {}// 注意这里没有作为成员函数重载
};// 重载运算符作为全局函数并声明为友元以便可以访问MyClass的内部数据
std::ostream operator(std::ostream os, const MyClass obj) {os obj.value; // 假设我们要输出MyClass对象的value成员return os;
}int main() {MyClass myObject(10);std::cout myObject; // 正确地把myObject的内容输出到std::cout
}多态
在讲解多态之前我们先来回顾下继承因为多态是建立在类的继承关系之上的。
简单来说: 继承允许我们基于一个已有的类称为基类来创建新的类称为派生类。派生类继承了基类的属性和方法并且可以添加自己的属性和方法或者重写继承来的方法。这为代码复用提供了一个强大的机制。
多态字面意思是“多种形态”。在C中它允许我们通过一个共同的接口来操作不同的数据类型。这听起来可能有点抽象不过别担心让我们通过一个例子来简化它。
想象一下你在动物园里看到了各种各样的动物。虽然每种动物都有自己独特的叫声但是你可以通过一个统一的行为“发出声音”来描述它们的共性。在C中我们可以将这种“发出声音”的行为抽象成一个共同的接口然后让每种动物类根据自己的特性来实现这个接口。
多态的分类
在C中多态主要以两种形式出现编译时多态和运行时多态。 编译时多态也称为静态多态主要是通过函数重载和模板实现的。函数重载允许你在同一个作用域内创建多个同名函数只要它们的参数列表不同即可。编译器根据调用函数时提供的参数类型和数量来决定调用哪个函数。 运行时多态也称为动态多态是通过虚函数和继承实现的。这允许你在基类中定义一个接口并在派生类中以不同的方式实现该接口。运行时多态的关键在于你在代码运行时才确定调用哪个函数。
运行时多态的实现
要实现C的运行时多态你需要掌握两个核心概念虚函数和指针或引用。
虚函数
虚函数是在基类中使用关键字 virtual 声明的函数它可以在派生类中被重覆盖覆盖指的是派生类被重写的函数和基类声明的虚函数具有相同的函数声明。这样当你通过基类的指针或引用调用虚函数时C会根据对象的实际类型来决定调用哪个版本的函数。
简单示例
让我们回到动物园的例子如果“动物”是一个基类“狗”和“猫”是派生类那么即使我们有一个指向“动物”的指针我们也可以用它来调用“狗”和“猫”特有的方法。
代码示例
class Animal {
public:virtual void speak() { cout Some sound endl; }
};class Dog : public Animal {
public:void speak() { cout Woof endl; }
};class Cat : public Animal {
public:void speak() { cout Meow endl; }
};
int main() {Animal* myAnimal new Dog();myAnimal-speak(); // Outputs: WoofmyAnimal new Cat();myAnimal-speak(); // Outputs: Meowdelete myAnimal; // Assuming myAnimal now points to Cat, delete the Cat objectreturn 0;
}在这个例子中我们定义了一个基类 Animal 和两个派生类 Dog 和 Cat。每个类都有一个 speak 函数但实现各不相同。通过基类指针调用 speak 时C运行时会根据对象的实际类型来决定调用 Dog 的 speak 还是 Cat 的 speak。
这里问个问题myAnimal是基类指针为什么调用的是派生类(Cat类和Dog类)的方法
其实就是通过多态和虚函数机制来实现的。简单来说 虚函数在基类中用virtual关键字声明的函数。派生类可以重写这些函数。 虚表指针每个包含虚函数的类对象都有一个指针vptr指向其类的虚表。 虚表每个包含虚函数的类都有一个虚函数表简称vtable里面存储了虚函数的地址。
注意虚函数表vtable是在编译期间确定的而虚表指针vptr是在每个对象被构造时创建并初始化的。(这个也是面试常考的点)
当通过基类指针调用虚函数时程序会使用这个指针指向的对象的虚表来确定实际调用哪个函数(这个过程是在运行时做的)。这样即便是通过基类指针程序也能调用到派生类中重写的方法实现了多态。
在这个例子中Animal 类中的 speak 函数被声明为 virtual这使得 Dog 和 Cat类能够提供自己的speak函数实现。当通过类型为 Animal* 的指针 myAnimal 调用 speak 函数时C 运行时会检查 myAnimal 实际指向的对象类型Dog或Cat并调用那个类型的 speak 函数。
纯虚函数和抽象类
当我们希望定义一个通用接口但又不想在基类中提供任何具体实现时该怎么办。使用纯虚函数和抽象类即可实现。
纯虚函数
纯虚函数是一种特殊的虚函数在基类中声明但不提供实现不定义函数体并且要求派生类必须提供具体的实现。这通过在函数声明的末尾加上 0来实现。
纯虚函数的存在使得基类变成所谓的抽象类这意味着它不能被直接实例化。这样抽象类为派生类定义了一个或多个必须实现的接口从而实现了一个完全抽象的概念层。
抽象类
抽象类是包含至少一个纯虚函数的类。它主要用作其他类的基类定义了一组接口派生类通过实现这些接口实现多态性。抽象类提供了一种强制派生类遵守特定设计契约的机制。
回到我们的动物园例子假设我们想要强制每种动物都必须实现自己的“发出声音”的方法但在“动物”这一概念层面我们无法给出一个具体的实现。这就是纯虚函数和抽象类发挥作用的地方。
// 抽象基类Animal
class Animal {
public:// 纯虚函数virtual void speak() const 0;virtual ~Animal() {} // 虚析构函数保证派生类的析构函数被调用
};// Dog类继承自Animal并实现speak方法
class Dog : public Animal {
public:void speak() const {cout Dog says: Woof! endl;}
};// Cat类继承自Animal并实现speak方法
class Cat : public Animal {
public:void speak() const {cout Cat says: Meow! endl;}
};void letAnimalSpeak(const Animal* animal) {animal-speak(); // 动态绑定speak方法
}int main() {Dog dog;Cat cat;letAnimalSpeak(dog);letAnimalSpeak(cat);// Animal animal; // 错误不能实例化抽象类return 0;
}
在这个示例中Animal类成为了一个抽象基类因为它包含了一个纯虚函数 speak。我们不能直接实例化 Animal 类但我们可以通过它的派生类 Dog 和 Cat 来实例化对象并且通过 Animal 类的引用或指针来调用它们各自的 speak 方法。
通过将 speak 方法定义为纯虚函数我们确保了所有 Animal 的派生类都必须实现自己的 speak 方法这样每种动物都有自己独特的发声方式。同时这也展示了运行时多态的强大之处即使是通过 Animal 类型的引用或指针程序在运行时也能正确调用到派生类对象的 speak 方法。
引入纯虚函数和抽象类后我们的代码设计变得更加清晰和严格。这种方式不仅强制派生类遵守一定的规则也提供了一个明确的、可扩展的接口框架。
使用多态的好处 多态的使用提供了几个优点
代码的可复用性可以通过基类接口编写通用的代码这些代码能够与任何派生类对象协同工作从而减少代码重复。代码的可扩展性新增派生类时不需要修改现有的基类代码或其他派生类代码只需覆盖基类的虚函数即可。接口的一致性派生类可以有不同的实现但是共享相同的基类接口使得接口一致、清晰。
在讲解虚函数的时候我们提到了覆盖然而在C中也存在另外一个相似的概念叫隐藏这两者也是比较容易混淆的接下来我们来看下覆盖和隐藏是什么以及它们之间的区别
覆盖和隐藏
在C中函数覆盖和函数隐藏是面向对象编程中的两个基本概念它们都涉及到派生类子类与基类父类之间方法的关系。
函数覆盖Function Overriding
当派生类中的成员函数与基类中的一个虚函数具有相同的签名即相同的函数名称、返回类型及参数列表时我们说派生类的函数覆盖了overriding基类的函数。函数覆盖是实现多态的关键机制之一。
覆盖发生在派生类与基类之间的虚函数上。覆盖的目的是在派生类中提供一个特定实现替换掉基类中的默认实现。
示例
class Base {
public:virtual void display() {std::cout Display of Base std::endl;}
};class Derived : public Base {
public:void display() override { // 覆盖基类的display函数std::cout Display of Derived std::endl;}
};int main() {Base* ptr new Derived();ptr-display(); // 调用Derived类的display方法delete ptr;return 0;
}在这个例子中Derived 类的 display 函数覆盖了 Base 类的 display 函数。通过基类指针调用 display 时实际上调用的是 Derived 类的实现。这其实就是所为的多态。
函数隐藏Function Hiding
当派生类中的函数与基类中的某个函数具有相同的名称但是签名不同则我们说派生类中的函数隐藏了hiding基类中的同名函数。
隐藏与覆盖不同它发生在所有同名函数上无论它们是否为虚函数。隐藏的发生仅仅因为函数的名称相同。
示例
class Base {
public:void display() {std::cout Display of Base std::endl;}
};class Derived : public Base {
public:void display(int) { // 隐藏了基类的display函数std::cout Display of Derived with parameter std::endl;}
};int main() {Derived obj;obj.display(5); // 调用Derived类的display方法// obj.display(); // 错误Base类的display方法被隐藏obj.Base::display(); // 明确调用Base类的display方法return 0;
}在这个例子中Derived 类的 display 函数隐藏了 Base 类的 display 函数因为它们的签名不同。尝试直接调用没有参数的 display() 会导致编译错误除非我们明确指定要调用 Base 类的版本。
类型转换
C提供了四种类型转换运算符用于在不同类型之间进行显式转换。这些转换方式比C语言中的传统转换提供了更好的类型安全性和可读性。
1. 静态类型转换static_cast
static_cast是用于类型之间转换的最常见形式它在编译时检查转换的合法性。如果转换不合法编译时会报错。它主要用于以下场景
基本数据类型的转换如整型与浮点型之间的转换。类层次结构中的向上转换从派生类到基类这是安全的。类层次结构中的向下转换从基类到派生类可能不安全因为基类指针可能并不真正指向一个派生类对象。void指针的转换将void转换为具体类型的指针或将具体类型的指针转换为void。
基本数据类型的转换示例
double d 10.5;
int i static_castint(d); // double转int类层次结构中的向上转换、向下转换代码示例
class Base {
public:void baseMethod() { std::cout Base method\n; }
};
class Derived : public Base {
public:void derivedMethod() { std::cout Derived method\n; }
};// 向上转换
Derived derivedObj;
Base* basePtr static_castBase*(derivedObj); // 安全的向上转换
basePtr-baseMethod(); // 正常可以访问基类方法// 向下转换
Base baseObj;
Derived* derivedPtr static_castDerived*(baseObj); // 不安全的向下转换
// derivedPtr-derivedMethod(); // 不安全baseObj不是Derived的实例2. 常量类型转换const_cast
const_cast主要用于修改类型的const属性包括去除const属性允许修改原本被声明为const的变量。
代码示例
const int a 10;
int b const_castint(a); // 去除const属性
b 20; // 修改成功但修改const变量是未定义行为
cout aendl; // 10
cout bendl; // 203. 动态类型转换dynamic_cast
dynamic_cast是C中用于在类的继承体系内进行类型转换的操作符特别适用于执行安全的向下转换。向下转换是指将基类的指针或引用转换为派生类的指针或引用。这种转换在运行时检查对象的实际类型以确保转换的合法性和安全性.
dynamic_cast向下转换代码示例
假设有一个基类 Base 和一个从 Base 派生的类 Derived
class Base {
public:virtual void print() {cout Base class endl;}virtual ~Base() {}
};class Derived : public Base {
public:void print() override {cout Derived class endl;}void specificFunction() {cout Derived class specific function endl;}
};现在如果我们想安全地将基类 Base 的指针转换为派生类 Derived 的指针并调用派生类的特定函数我们可以使用dynamic_cast
int main() {Base* basePtr new Derived();basePtr-print(); // 输出: Derived class// 安全的向下转换Derived* derivedPtr dynamic_castDerived*(basePtr);if (derivedPtr ! nullptr) {derivedPtr-specificFunction(); // 输出: Derived class specific function} else {cout Conversion failed. endl;}delete basePtr;return 0;
}在这个示例中basePtr 实际上指向一个 Derived 类的对象因此使用 dynamic_cast 将 basePtr 转换为 Derived* 类型是安全的并且转换成功。这允许我们安全地调用 Derived 类的 specificFunction 方法。
dynamic_cast类型转换如何使用讲完了接下来我们来看下 dynamic_cast类型转换的具体过程是怎样的
dynamic_cast的工作原理
dynamic_cast利用 C 的运行时类型信息RTTI机制来检查转换的安全性。它在运行时检查对象的实际类型以确保所执行的转换是合法的。这种检查使得dynamic_cast比其他类型转换操作符如static_cast或reinterpret_cast更安全但也带来了一定的性能开销。
RTTI 是什么
C的RTTIRuntime Type Information运行时类型信息是一种机制它允许C程序在运行时查询和操作对象的类型信息。这种能力使得dynamic_cast能够在执行类型转换前检查转换是否安全从而确保类型转换的正确性和安全性。
dynamic_cast类型转换的具体过程
1. 确定对象的实际类型
访问虚函数表vtable在C中每个具有虚函数的类的对象都会有一个隐藏的指针称为虚表指针vptr指向一个静态的虚函数表vtable。vtable主要用于支持多态性即在运行时决定调用哪个虚函数。
类型信息RTTI在vtable中除了虚函数的地址外vtable还包含了指向特定的类型信息的指针这里说的类型信息就是RTTI。RTTI的核心是type_info类的对象它为每个类提供了唯一的类型标识。
2. 利用RTTI确定实际类型
当使用dynamic_cast进行类型转换时C运行时会查找原对象的vtable通过其中的RTTI信息即指向type_info对象的指针来获取对象的实际类型。一旦获得了对象的实际类型信息dynamic_cast接着检查这个类型与目标类型的关系。对于向下转换基类指针转换为派生类指针它验证目标派生类是否确实是源对象实际类型的派生类或相同类型。
在多态的使用场景中上面提到的原对象指的是一个指向基类的指针或引用指向的对象。 目标派生类指的是我们希望将原对象的基类指针或引用转换到的目标类。
Base* basePtr new Derived();
Derived* derivedPtr dynamic_castDerived*(basePtr);对于上面的代码示例原对象指的就是 basePtr 基类指针指向的 Derived 对象。目标派生类指的是 Derived 类。
3.验证转换的合法性并执行转换或失败处理
如果转换合法dynamic_cast修改源指针或引用使其指向正确的目标类型的对象。如果转换不合法对于指针类型dynamic_cast返回nullptr表示转换失败。 对于引用类型dynamic_cast 抛出 std::bad_cast 异常因为引用不能为 nullptr。
4.重新解释类型转换 reinterpret_cast
reinterpret_cast是C中一种强大但需谨慎使用的类型转换操作符。它允许开发者在几乎任何指针类型之间进行转换也支持指针与足够大的整数类型之间的转换。其基本作用是重新解释数据的位模式但不改变数据本身。
由于 reinterpret_cast 不进行类型检查和转换安全性保证使用时需要特别注意以防止未定义行为的发生。
指针类型转换
reinterpret_cast 可以用来将一个指针类型转换为另一个指针类型即便这两个类型之间并无直接的关联。这种转换基本上是在告诉编译器将内存地址当作另一种类型来解释但不改变位模式本身。这种转换不会进行任何类型安全检查因此非常危险且易于产生错误。因此在解引用转换后的指针之前你需要确保转换是有意义的。
指针类型转换示例
char c a;
char* cp c;
// 将char*转换为int*虽然不安全但可以编译通过
int* ip reinterpret_castint*(cp);
cout*ipendl; // 输出随机值指针与整数类型之间的转换
reinterpret_cast也可以用于将指针转换为整数类型或者相反。这在需要在整数和指针之间进行转换例如当与需要整数参数的底层系统调用交互时非常有用。为了安全地执行这种转换整数类型必须足够大以存储指针值通常使用 uintptr_t 或 intptr_t。
示例代码
#include iostream
#include cstdint // 包含uintptr_t定义int main() {int a 42;// 将int指针转换为整数uintptr_t ptrAsInt reinterpret_castuintptr_t(a);std::cout The pointer as integer: ptrAsInt std::endl;// 将整数转换回int指针int* aPtrAgain reinterpret_castint*(ptrAsInt);std::cout The integer as pointer: *aPtrAgain std::endl;return 0;
}指针与整数类型之间的转换的使用场景 系统级调用或API: 一些底层的系统调用或API可能要求使用整数类型的“句柄”来代表资源或对象。在这些情况下如果资源或对象由C管理并通过指针访问我们可以临时将指针转换为整数类型的句柄进行调用然后再转换回指针进行操作。 回调函数与用户数据: 在使用回调函数时通常需要提供一个指向用户数据的指针。如果回调函数的接口仅允许传递整数类型的用户数据我们可以将指针转换为整数进行传递然后在回调函数中再转换回指针以访问实际的用户数据。
示例代码使用回调函数
假设我们有一个C库该库提供了一个设置回调函数的API但API要求回调函数的用户数据必须是uintptr_t类型
#include iostream
#include cstdintvoid MyCallback(uintptr_t userData) {// 在回调中将整数还原回指针std::string* str reinterpret_caststd::string*(userData);std::cout *str std::endl;
}void RegisterCallback(void(*callback)(uintptr_t), std::string* userData) {// 调用回调函数将指针作为整数传递callback(reinterpret_castuintptr_t(userData));
}int main() {std::string myData Hello, callback!;RegisterCallback(MyCallback, myData);return 0;
}模板
C的模板是一种强大的编程特性它允许程序员编写与类型无关的代码也就是所谓的泛型编程。这使得我们可以编写一个通用的代码框架它可以用于多种数据类型。使用模板可以大大提高代码的复用性和灵活性。
C 模板主要有两种形式函数模板和类模板
在讲解函数模板和类模板之前我们先来了解下 模板参数
在 C 模板编程中模板参数是定义模板时指定的一种占位符它在模板实例化时被具体的类型或值所替代。模板参数使模板具有泛型能够适应不同的数据类型或值。C 中的模板参数主要分为两类类型参数和非类型参数。
类型参数
类型参数允许在模板定义时指定一些占位符类型这些类型在模板实例化时被具体的类型所替代。这意味着你可以编写一个通用的模板然后用不同的类型来实例化它生成针对那些类型的特化版本。
类型参数声明方式使用关键字 typename 或 class 来声明。
template typename T
T max(T x, T y) {// 函数实现
}在上述示例中T是一个类型参数表示 max 函数的两个参数可以接受任何类型。
非类型参数
非类型参数允许你将一个或多个常量值作为参数传递给模板。非类型参数必须是一个常量表达式因为模板在编译时实例化。
下面来看类模板如何使用非类型参数
template T, size_t N
class FixedArray {T data[N]; // N 是一个非类型参数
public:T operator[](size_t index) { return data[index]; }const T operator[](size_t index) const { return data[index]; }constexpr size_t size() const { return N; }
};
在这个示例中N是一个非类型参数它指定了FixedArray的大小。
非类型参数大多数使用在类模板中虽然非类型参数在函数模板中的使用不如在类模板中那么频繁但在某些情况下它们仍然非常有用特别是当你需要根据编译时常量来调整函数行为时。
template typename T, int increment
T addIncrement(T value) {return value increment;
}在这个例子中addIncrement 函数模板通过非类型参数 increment 允许在编译时确定增加的量这可以在不同的调用中提供不同的增量值。
函数模板
函数模板允许我们创建一个函数原型它可以用不同的数据类型来实例化。这意味着我们可以用一个函数模板来创建一系列执行相似操作的函数而无需为每种数据类型编写重复的代码。
template typename T
T max(T x, T y) {return x y ? x : y;
}调用函数模板
1. 自动类型推导::
int a 5, b 10;
std::cout Max of a and b is max(a, b) std::endl; double c 3.5, d 2.5;
std::cout Max of c and d is max(c, d) std::endl;// 或者这样调用
max(3, 5)2. 显式指定模板参数类型:
std::cout Max of 2 and 3 is maxint(2, 3) std::endl;
std::cout Max of 2.5 and 3.5 is maxdouble(2.5, 3.5) std::endl;上面整型的调用函数模板实际上会被实例化为一个接受两个 int 类型参数的函数版本
int max(int x, int y) {return x y ? x : y;
}而对于double类型的调用函数模板会被实例化为一个接受两个 double 类型参数的函数版本。
double max(double x, double y) {return x y ? x : y;
}类模板
类模板与函数模板类似允许我们定义一个类蓝图用于生成处理不同数据类型的类。
template typename T
class Box {
public:Box(T value) : value(value) {}T getValue() const { return value; }
private:T value;
};// 创建类模板实例
Boxint intBox(123);
std::cout Value in intBox: intBox.getValue() std::endl;Boxstd::string stringBox(Hello Templates);
std::cout Value in stringBox: stringBox.getValue() std::endl;通过以上例子我们了解了模板的基本使用以及它是如何提高代码的复用性的。
上面的max函数模板的通用性确保了它可以用于整数、浮点数甚至是字符串等多种类型展现了模板编程的灵活性。然而这种通用性有时候也是一把双刃剑。以指针类型为例如果我们使用上述max函数比较两个指针它实际上会比较指针的地址而不是指针所指向的值这可能并不是我们期望的行为。
在这种情况下C提供了一种强大的机制来优化和定制模板行为——模板特化。模板特化允许我们为特定的类型或值集合提供专门的实现以此来处理那些需要特殊考虑的特定情况。模板特化分为两大类全特化Explicit Specialization和偏特化Partial Specialization。
全特化
全特化Explicit Specialization是为一个已有的模板定义提供一个特定版本的过程这个特定版本适用于特定的类型或值。全特化意味着为模板的所有参数指定具体的类型或值。全特化不再是模板而是对模板的一个特定实例提供了一个完全定制的实现。
类模板全特化
当你想为一个特定类型提供一个完全不同的类模板实现时可以使用全特化。
语法
template // 空尖括号代表全特化的声明
class ClassNameType {// 特化的实现
};示例
templatetypename T
class MyClass { // 通用模板
public:void function() {std::cout Generic MyClass\n;}
};template
class MyClassint { // 全特化为int类型
public:void function() {std::cout Specialized MyClass for int\n;}
};
函数模板全特化
与类模板相似你也可以为特定类型提供特定的函数模板实现。
语法
template
ReturnType functionNameSpecificType(parameters) {// 特化的实现
}举一个函数模板全特化的例子比如对于前面的max函数我们想要对指针类型进行特化使其比较指针所指向的值而不是指针地址
template typename T
T max(T x, T y) { // 通用模板return x y ? x : y;
}template //使用全特化
const char* maxconst char*(const char* a, const char* b) {return strcmp(a, b) 0 ? a : b;
}通过这个例子展示如何为特定类型在这里是const char*类型的字符串提供特化实现。
偏特化
偏特化允许你为模板的一部分参数提供具体的类型或值而其余参数仍然保持泛型。偏特化仅适用于类模板不能用于函数模板。通过偏特化你可以对模板的部分参数施加约束从而为特定的类型组合提供特定的实现。
语法示例
template typename T, typename U
class MyClass {}; // 通用模板template typename U
class MyClassint, U {}; // 对第一个参数为int的偏特化代码示例
#include iostreamtemplatetypename T, typename U
class MyClass { // 原始模板
public:void function() {std::cout Generic MyClassT, U\n;}
};templatetypename U
class MyClassint, U { // 偏特化其中一个参数为int
public:void function() {std::cout Partially Specialized MyClassint, U\n;}
};int main() {MyClassdouble, double myClass1; // 将使用原始模板myClass1.function();MyClassint, double myClass2; // 将使用偏特化模板myClass2.function();return 0;
}内存管理
在C中内存管理是一个非常重要的概念它关系到程序的性能和稳定性。C提供了多种内存管理方式包括自动、静态、动态分配等。让我们一步一步来了解。
为了更好地理解这些内存管理方式及其应用场景我们首先需要了解C/C程序在运行时的内存布局。这里我介绍 Linux C/C 程序的内存布局因为大多数 C/C 程序都是运行在 Linux 操作系统上因此有必要了解下。
Linux C/C 程序的内存布局图示 上图中从下往上地址是增加的0-3G属于用户空间3G-4G 属于内核空间.
接下来我们对上面图示的各个区段作个详细的说明
内核空间Kernel Space
Kernel space内核空间指的是操作系统内核所占用的内存区域。这部分内存是保留给操作系统内核的用于执行核心的系统任务和硬件操作。出于安全和稳定性的考虑用户程序通常无法直接访问这部分内存。在多数操作系统中内核空间位于内存地址的高区域。在32位Linux系统上通常最高的1GB内存如地址从0xC0000000到0xFFFFFFFF被保留给内核空间而剩下的下3GB内存用于用户空间。
用户空间User Space 栈Stack 栈用于存放函数的局部变量、函数参数和返回地址。栈有着LIFO后进先出的特性每当进入一个新的函数调用时就会在栈上为其分配空间函数返回时则释放这些空间。栈的大小通常有限并且由操作系统预先定义。 内存映射段(Memory Mapping Segment) 内存映射段是一块可以被用来映射文件内容到进程地址空间的内存区域。这不仅包括用于动态库如libc.so等的映射还包括程序运行时可能使用的任何匿名映射或文件映射。 堆Heap 堆用于动态内存分配由new和delete或malloc和free控制。不同于栈堆上的内存分配和释放是不自动的需要程序员手动管理。堆的大小相对更灵活受限于系统的可用内存。 BSS段Block Started by Symbol BSS段全称为“Block Started by Symbol”主要用于存储程序中未初始化的全局变量和静态变量。与数据段存放已初始化的全局变量和静态变量相对BSS段的特点是在程序启动之前操作系统会自动将其内容初始化为零。这意味着如果你在程序中声明了一个未初始化的全局或静态变量它会被放在BSS段。 数据区 这部分内存用于存放全局变量和静态变量。不同于栈和堆上的变量全局/静态变量的生命周期贯穿整个程序运行期间从程序开始执行时分配到程序结束时才被释放。 代码区 存放程序的二进制代码即编译后的机器指令。这部分内存是只读的。
接下来我们详细来聊下C的多种内存管理方式包括自动、静态、动态分配。
常见的内存管理方式
自动存储Stack Allocation
最简单的内存管理方式是自动存储也就是在函数内部声明的局部变量。这些变量在函数被调用时自动分配内存在栈上分配并在函数返回时自动释放。
示例
void function() {int localVar 5; // 自动存储函数结束时自动销毁// ...
}静态存储Static Allocation
静态存储用于全局变量和静态变量其生命周期贯穿整个程序运行期间。静态变量只被初始化一次在首次加载时分配内存。
示例
void function() {static int staticVar 5; // 静态存储整个程序运行期间持续存在// ...
}动态存储Dynamic Allocation
动态存储是C内存管理的核心允许在运行时分配任意大小的内存。它使用new和delete操作符来手动管理内存。
使用new和delete
示例
int* ptr new int; // 动态分配一个int
*ptr 5; // 给分配的int赋值
std::cout *ptr std::endl; // 使用分配的内存
delete ptr; // 释放内存使用new[]和delete[]管理数组
示例
int* array new int[5]; // 动态分配一个有5个整数的数组
for (int i 0; i 5; i) {array[i] i * i;
}
delete[] array; // 释放数组现在大家已经了解了C中的内存管理基础接下来我们将探讨如何更安全、更有效地管理资源。C提供了一个强大的编程范式称为RAII资源获取即初始化。
RAII
“Resource Acquisition Is Initialization” (RAII) 是一种在C中管理资源如内存、文件句柄等的编程模式。在RAII模式下资源的分配获取发生在构造函数中资源的释放归还发生在析构函数中。这种方式利用了C自动调用析构函数的特性确保了资源总是被正确释放即使在面对异常情况时也不例外。
智能指针
通过RAII的介绍我们已经认识到构造函数和析构函数在资源管理中的重要性。然而在现实的编程实践中尤其是面对复杂的资源管理需求时单靠构造函数和析构函数可能不足以保证资源的安全和高效管理。这时智能指针的概念应运而生。
智能指针实质上是一种行为类似于指针的对象但它们包裹了原始指针自动管理指向的资源。智能指针的核心理念正是基于RAII模式——通过对象的生命周期来管理资源。当智能指针的实例被创建时它获取一个资源比如动态分配的内存当智能指针实例销毁时它释放那个资源。
C11 标准库中提供了几种智能指针如std::unique_ptr、std::shared_ptr和std::weak_ptr但是我这里不讲解C11 标准库的智能指针而是重点讲解boost库里的智能指针。这两者的实现原理类似。后续会出一篇专门讲解 C11新特性的文章。
在C11之前C社区已经有一套成熟的解决方案来处理资源管理问题那就是boost库提供的智能指针。
boost库是C标准的实验田很多在boost中实现的功能后来都被纳入了C标准库。例如C11标准中的智能指针std::shared_ptr和std::unique_ptr、基于范围的for循环、无序容器等都是从Boost库中借鉴或直接采用的。因此可以说Boost对C标准的发展有着重要的贡献。
boost库智能指针有哪些
1. boost::scoped_ptr
boost::scoped_ptr是一种简单的智能指针用于管理在作用域内分配的对象。它不能传递所有权即不能从一个scoped_ptr拷贝或赋值给另一个scoped_ptr。当scoped_ptr离开作用域时它所管理的对象会被自动销毁。
使用示例
#include boost/scoped_ptr.hpp
#include iostreamclass MyClass {
public:MyClass() { std::cout MyClass constructed\n; }~MyClass() { std::cout MyClass destroyed\n; }
};int main() {boost::scoped_ptrMyClass ptr(new MyClass);// ptr在这里可用
} // ptr离开作用域自动销毁MyClass实例2. boost::shared_ptr
boost::shared_ptr是一种引用计数的智能指针也称共享型智能指针允许多个shared_ptr实例共享同一个对象的所有权。当最后一个引用shared_ptr实例被销毁或重新指向另一个对象时所管理的对象会被自动释放。
#include boost/shared_ptr.hpp
#include iostreamclass MyClass {
public:MyClass() { std::cout MyClass constructed\n; }~MyClass() { std::cout MyClass destroyed\n; }
};int main() {boost::shared_ptrMyClass ptr1(new MyClass);{boost::shared_ptrMyClass ptr2 ptr1; // ptr1和ptr2共享对象} // ptr2离开作用域对象不会被销毁因为ptr1仍然存在引用计数不为0
} // ptr1离开作用域对象被销毁引用计数为0对于上面提到的引用计数大家可以简单理解为一个非负整型数值.
3. boost::weak_ptr
boost::weak_ptr专门设计用于与boost::shared_ptr协同工作解决潜在的循环引用问题。循环引用发生在两个或多个对象通过shared_ptr相互引用时导致它们的引用计数永远不会归零进而导致内存泄漏。weak_ptr提供了一种机制允许对这些对象进行观察而不增加引用计数。
weak_ptr的几个特性
观察者boost::weak_ptr是对boost::shared_ptr所管理对象的非拥有性引用观察者。它允许你访问对象但不会延长对象的生命周期。临时升级虽然weak_ptr本身不能直接访问对象但它可以被临时升级为一个shared_ptr如果对象仍然存在以安全地访问对象。解决循环引用在使用shared_ptr管理相互引用的对象时容易产生循环引用导致对象无法被释放。weak_ptr不参与引用计数因此可以打破这种循环避免内存泄漏。
weak_ptr基本用法
1. 创建weak_ptr
weak_ptr通常通过与一个shared_ptr关联来创建
boost::shared_ptrint sp(new int(42)); // 创建shared_ptr
boost::weak_ptrint wp(sp); // 通过shared_ptr创建weak_ptr2. 使用weak_ptr
要访问 weak_ptr 所观察的对象需要将它临时升级为shared_ptr这可以通过调用weak_ptr的lock方法实现
boost::shared_ptrint sp wp.lock(); // 尝试将weak_ptr升级为shared_ptr
if (sp) {std::cout *sp std::endl; // 安全使用
} else {std::cout 对象已被销毁 std::endl;
}3. 解决循环引用示例
考虑两个类ClassA和ClassB它们通过shared_ptr相互持有对方
#include boost/shared_ptr.hpp
#include iostreamclass ClassB;class ClassA {
public:boost::shared_ptrClassB bPtr;~ClassA() {std::cout ClassA destroyed\n;}
};class ClassB {
public:boost::shared_ptrClassA aPtr;~ClassB() {std::cout ClassB destroyed\n;}
};int main() {boost::shared_ptrClassA a(new ClassA());boost::shared_ptrClassB b(new ClassB());a-bPtr b; // a持有b的shared_ptrb-aPtr a; // b持有a的shared_ptr形成循环引用return 0;
}在这个示例中main 函数中创建了两个shared_ptr对象a和b分别指向 ClassA 和ClassB 的新实例。然后我们通过 a-bPtr b;和b-aPtr a;让这两个实例相互持有对方从而创建了循环引用。
由于存在循环引用当 main 函数执行完毕尽管a和b的作用域结束它们应该被销毁但 ClassA 和 ClassB 的实例的引用计数并没有降到零因为它们相互引用导致析构函数没有被调用从而引发内存泄漏。
如何解决使用weak_ptr可以解决这个问题
可以将其中一个类的shared_ptr成员改为weak_ptr。这样做可以打破循环引用。
class ClassB;class ClassA {
public:// 使用weak_ptr代替shared_ptrboost::weak_ptrClassB bPtr;~ClassA() {std::cout ClassA destroyed\n;}
};class ClassB {
public:boost::shared_ptrClassA aPtr;~ClassB() {std::cout ClassB destroyed\n;}
};
int main() {boost::shared_ptrClassA a(new ClassA());boost::shared_ptrClassB b(new ClassB());a-bPtr b; // ClassA中持有ClassB的弱引用b-aPtr a; // ClassB中持有ClassA的强引用形成非对称的引用// 当main结束时a和b的shared_ptr都会被销毁。// b的shared_ptr被销毁时由于ClassA中持有的是ClassB的weak_ptr不会阻止ClassB对象的销毁。// 因此ClassB被销毁后ClassA中的weak_ptr变为悬挂指针但ClassA对象也会随之被安全销毁。return 0;
}这里大家只需要掌握这几种智能指针的简单使用即可后面笔者有计划写一篇关于智能指针实现原理的文章从源码实现的角度来讲解。帮助大家更好的理解智能指针。
内存泄漏
在C中内存泄漏是指程序分配的内存没有被正确释放导致程序不再能够使用那部分内存。内存泄漏在长时间运行的程序中尤为危险因为它们会逐渐消耗掉所有可用的内存资源可能导致程序崩溃或系统变得缓慢。
内存泄露的原因
在C中内存泄露通常由以下几个原因引起
动态分配内存未释放使用new或malloc等分配内存后未使用delete或free释放。资源未释放除了内存外文件句柄、数据库连接等资源未被关闭或释放也会造成资源泄露。循环引用使用智能指针如std::shared_ptr时不当的循环引用会导致对象无法被自动销毁。异常安全性问题 当函数或方法在执行过程中抛出异常而这个函数或方法之前进行了资源分配如动态内存分配如果没有正确地处理异常例如通过异常安全的智能指针或try/catch块来确保资源被释放那么原本应该在函数结束时释放的资源可能会因为异常的抛出而遗漏。
内存泄露的检测
检测内存泄露通常可以通过以下几种方式
1. 代码审查通过审查代码逻辑检查每次new的内存分配是否都有对应的delete释放。
2. 运行时工具 ValgrindLinux下一个强大的内存检测工具能够检测出内存泄露、内存越界等问题。Valgrind的优点在于它不需要对程序进行重新编译适用于几乎所有的二进制文件但缺点是运行速度较慢通常会让程序的执行速度降低10倍以上。 AddressSanitizer一个快速的内存错误检测工具能够检测出包括内存越界访问、使用后释放、堆栈缓冲区溢出等问题。与Valgrind相比ASan的主要优点是执行速度快一般只会让程序变慢2倍左右和提供精确的错误信息但它需要对程序进行重新编译并链接并且增加了程序的内存需求。
如何避免内存泄漏
1. 限制动态内存的使用
尽量减少动态内存分配的使用。许多情况下可以通过使用栈分配的变量或标准容器来代替动态分配的内存。这不仅可以减少内存泄漏的风险还可以提高程序的性能。
2. 使用智能指针尽量避免在代码中直接使用裸指针管理动态分配的内存。裸指针很容易导致内存泄漏因为它们不会自动释放所指向的内存。如果确实需要使用指针考虑使用智能指针来代替。
3. 使用容器类C标准库提供了多种容器如std::vector、std::list等这些容器在内部管理内存可以减少直接使用动态内存分配的需要。
4. 使用对象池 对于频繁创建和销毁的小对象使用对象池可以是一个有效的解决方案。对象池预先分配一定数量的对象并在需要时重用它们从而避免了频繁的动态内存分配和释放。
5. 定期检查和测试使用内存检测工具定期检查程序及早发现并修复内存泄漏问题。
异常处理
C中的异常处理是通过try、catch、throw关键字实现的旨在处理程序运行时可能出现的错误和异常情况。使用异常处理可以使错误处理代码和正常业务逻辑分离使程序结构更清晰更易于维护。
基本概念
throw当检测到错误条件时程序可以通过throw关键字抛出一个异常。抛出的异常可以是预定义的数据类型也可以是自定义类型。trytry块包含可能抛出异常的代码。如果在try块中的代码抛出了异常执行将跳转到相应的catch块。catchcatch块用于捕获和处理异常。可以定义多个catch块来捕获不同类型的异常。
示例代码
下面是一个简单的示例演示了如何使用C的异常处理机制
#include iostream
using namespace std;int divide(int a, int b) {if (b 0) {throw Division by zero error; // 抛出异常}return a / b;
}int main() {try {cout divide(10, 2) endl; // 正常情况cout divide(10, 0) endl; // 这里将抛出异常} catch (const char* msg) {cerr Error: msg endl; // 捕获并处理异常}return 0;
}在上面的示例中divide函数在除数为零时抛出一个异常main函数中的 try 块捕获并处理了这个异常。
自定义异常
除了使用预定义类型作为异常外C还允许定义自定义异常类。通过继承标准的exception类来创建自定义异常更为方便
#include iostream
#include exception
using namespace std;// 自定义异常类
class MyException : public exception {
public:const char* what() const throw() {return Custom error occurred;}
};int main() {try {throw MyException();} catch (MyException e) {cout MyException caught endl;cout e.what() endl;} catch (exception e) {// 其他所有的异常}return 0;
}在这个示例中我们定义了一个名为MyException的自定义异常类并在main函数中抛出和捕获了这个异常。自定义异常类通过覆写 what 方法提供了异常的描述信息。
通过合理使用C的异常处理机制可以有效地管理程序中的错误情况提高程序的健壮性和可读性。
总结
本篇文章旨在提供一个关于C语言学习的指南以帮助初学者系统地掌握C编程的关键技能和概念。通过深入浅出的方式我们逐步解析了C开发的各个方面从基础的数据类型、函数使用到高级的面向对象编程技术如类和对象的操作、封装、继承、以及多态等。 数据类型和函数我们探讨了C的基本数据类型、枚举、复合以及派生数据类型这为理解C提供了坚实的基础。同时函数的定义、声明、调用以及参数传递等知识点构建了函数编程的框架。 面向对象编程详细讨论了类和对象的定义、成员变量和函数、构造函数和析构函数等概念强调了封装、继承和多态这三大面向对象编程的核心特性。特别地通过this指针、友元、运算符重载的讲解进一步拓展了面向对象的编程思维。 高级特性深入到模板编程介绍了类型参数、非类型参数、函数模板、类模板以及模板的特化这些内容展现了C泛型编程的强大能力。同时对C中的内存管理、异常处理进行了探讨了解了怎样编写高效且安全的C代码。
这篇文章主要是给大家提供一个如何快速学习 C 的指南不知道怎样学习 C 的朋友可以 按照我列的知识点去看书去实践掌握 C 语言应该会很快的。记住一定要多敲代码多实践
最后
如果你单纯去学习C、C语言是干不了任何事情的作为与硬件和操作系统打交道的计算机底层语言要想掌握 C和C你还得学习这几门课程计算机组成原理、操作系统、数据结构。甚至还得了解汇编语言。除此之外还需要学习 Linux 系统编程以及网络编程相关知识。
如果你想学习 Linux 编程包括系统编程和网络编程相关的内容或者想了解计算机原理相关的知识那欢迎关注我的公众号「跟着小康学编程」微信搜索即可这里会定时更新计算机编程相关的技术文章感兴趣的读者可以关注一下。具体可访问关注小康微信公众号
另外大家在阅读这篇文章的时候如果觉得有问题的或者有不理解的知识点欢迎大家评论区询问我看到就会回复。大家也可以加我的微信jk-fwdkf备注「加群」有任何不理解得都可以咨询。
大家觉得讲的不错的话记得帮我点个赞呦. 也期待你们的关注