C学习笔记 基础知识整合(2024 9.19-10.7)

思维导图

7015d5635a8f40d5bf82922624b37cd1.jpeg

参考的学习资源:

        [1] 《明解C语言:入门篇(第3版)》

        [2] C 语言教程 | 菜鸟教程 (runoob.com)

        [3] 取消vs2022安全性警告

        [4] Kimi.ai

        [5] FittenCode Chat

更新日志:9.19-9.30完成了大部分基础内容

                  10.4-10.7完成了剩下两章的内容,以及完善了指针等重要内容

C语言:

C语言是一种通用的、过程式的编程语言,由丹尼斯·里奇在1972年开发。它是最古老、最广泛使用的编程语言之一。C语言通常用于嵌入式系统、操作系统和高性能计算应用。

C的主要特点:

  1. C语言提供了对硬件的直接控制

  2. C语言的精髓——指针,允许程序员直接操作内存,这为数据结构的实现和内存管理提供了极大的灵活性

  3. C语言编写的程序可以在多种操作系统和硬件平台上编译和运行,这使得C语言成为开发可移植软件的理想选择

  4. C语言是一种编译型语言,程序在运行前需要编译成机器码,这通常比解释型语言执行得更快,提供了更好的性能

  5. C语言支持结构化编程,使得代码更加模块化和易于维护

  6. C语言编写的程序通常具有较高的执行效率

一、基础概念

   1.变量 

变量是程序可操作的存储区的名称。每个变量都有特定的类型,类型决定了变量存储的大小和布局,该范围内的值都可以存储在内存中,运算符可应用于变量上。

变量的名称可以由字母、数字和下划线组成,必须以字母或下划线开头。

char myname[11] = "WilliTourt"; //定义字符数组myname,初始化包含字符串WilliTourt

int integer1, integer2; //定义两个整型变量
integer1 = 6; //给integer1初始化赋值
integer2 = integer1; //再把integer1的值赋给integer2

float pi = 3.14159; //浮点型变量

unsigned int OnlyPositiveNum = 123; //无符号整型

bool Switch = true; //布尔型

extern int ExternVariable; //外部整形变量

   2.常量

常量是固定值,值在定义后不能进行修改,在程序执行期间不会改变。这些固定的值,又叫做字面量

const float pi = 3.1415926f; //const关键字声明只读浮点型变量

#define pi 3.1415926 //#define预处理器定义常量

   3.关键字

Keywords是编程语言中预先保留的单词,它们具有特殊的意义和用途,用于表示语言的语法结构和功能。关键字不能用作变量名、函数名或其他标识符

关键字涵盖了变量声明、流程控制以及内存管理等多个方面。

常见的例子:auto、const、extern、break、continue、goto、enum、return、if、else、for、while、do、switch、case...

   4.标识符

...名字。各种名字。命名方法推荐如下:

  • 驼峰命名法:第一个单词的首字母小写,后续单词的首字母大写,如myVariableName
  • 帕斯卡命名法:所有单词的首字母都大写,如MyVariableName
  • 蛇形命名法:所有单词都小写,用下划线连接,如my_variable_name

   5.存储类

存储类定义C程序中变量/函数的存储位置、生命周期(变量或函数存在的时间长短)和作用域(可在程序的哪些部分被访问)。

auto 存储类

        是所有局部变量默认的存储类。它们在函数开始时被创建,在函数结束时被销毁。

void function(){
    auto int a = 1;
//此时a被创建
    /*...其他代码...*/
//运行结束时a被销毁
}

        如果auto类变量在声明时没有初始化,自动变量的值是未定义的。在这种情况下,读取值将得到一个不确定的值,这可能会导致程序行为异常或产生错误。所以应在声明变量后立即赋值。

static 存储类

        可以用来声明全局变量、局部变量和函数。

        它指示编译器在程序的生命周期内保持变量的存在,而不需要在每次它进入和离开作用域时进行创建和销毁。静态变量在程序中只被初始化一次,即使函数被调用多次,该变量的值也不会重置。

        >全局静态变量的生命周期贯穿整个程序的执行期间。

        >局部静态变量的生命周期贯穿整个程序的执行期间,但只在定义它们的函数或代码块内部有效。

static int a; //a会被初始化为0
    /*...代码...*/
void function(){
    static int b = 1; //b只会在程序开始时初始化一次,这意味着其值的改变可以被保留
    b++;
    /*...代码...*/
}

int main(){
    function();     //此时b == 2
    function();     //此时b == 3
    function();     //此时b == 4
    return 0;
}
    /*...代码...*/
//a和b均在程序结束时才结束生命周期

register 存储类

         用于建议编译器将局部变量存储在CPU寄存器中,而不是默认的RAM内存中。这样做可以提高变量的访问速度,因为寄存器的访问速度比RAM快得多。在需要频繁访问的变量上使用 register 存储类可以提高程序的运行速度

        ...但其声明的变量大小不能超过寄存器的大小(32位或64位),且不能使用&运算符来获取其地址,这是因为寄存器没有固定的内存地址。

extern 存储类

        用于指明变量或函数的定义位于另一个文件或在当前文件的其他位置。extern关键字本身并不分配存储空间,而是告诉编译器去其他地方查找变量或函数的定义。

extern void function(); // 声明了一个外部函数,但定义在另一个文件中

二、数据类型 

   1.基本数据类型

int:整型,用于表示整数。

char:字符型,用于表示字符。

        int / char 前可加 unsigned / signed 用于表示是否带符号。

char注意:

fb7e44aeec3f4a2bab5f9b75ddb5163d.png

float:单精度浮点型,用于表示实数。

double:双精度浮点型,用于表示更精确的实数。

bool:布尔值(真或假)。

尺寸限定符:

short:用于指定短整型(-32768 - 32767)。

long:用于指定长整型(-2147483648 - 2147483647)。

long long:用于指定更长的整型(-9223372036854775808 - 9223372036854775807)。

        加 unsigned / signed 用于表示是否带符号。

   2.类型转换

使用类型转换运算符 () 来把值显式地从一种类型转换为另一种类型,如下所示:

(type_name) expression

在需要把一种类型的值赋给不同类型的其他变量时,或进行不同类型的数据运算时,应该进行类型转换。

数据从大类型(如double、float)到小类型(如int、char)的转换称作强制类型转换,这样做通常会发生精度丢失和范围溢出(得到随机值或数值回绕)

int i = (int)3.14159; // 将 double 转换为 int, 结果为 3
short j = (short)2147483647; // 将 int 转换为 short, 结果未知(范围溢出了)

   3.其他数据类型

void 类型

无类型,用于表示没有值或没有返回值的函数。

枚举类型(enum)

用户定义的类型,用于表示一组命名的整数常量

enum Day {SUN, MON, TUE, WED, THU, FRI, SAT};

int main() {
    enum Day today;
    today = TUE;     //合法的赋值
    // today = 2;    //非法的赋值,因为2不是Day中的元素
    return 0;
}


enum Switch {ON = 1, OFF = 0}; //定义时指定值

当定义一个枚举类型时,如果不为枚举成员指定值,编译器会自动为它们分配整数值,从0开始,依次递增。 如上面的SUN会默认赋值为0。

结构体类型(struct)

用户定义的复合数据类型,可以包含不同类型的成员。数据成员的类型可以是基本数据类型,也可以是其他结构体类型、指针类型等。

定义结构体的基本语法如下:

struct 结构体标签(可选) {
    成员数据类型1 成员名称1;
    ...
    成员数据类型n 成员名称n;
} 结构体变量(可选)

可以在定义结构体的同时或之后定义结构体变量,可以在定义结构体变量时初始化它们。使用点运算符(.)来访问结构体成员。

struct Point {
    int x;
    int y;
}; //定义一个Point结构体

struct Point p1; //定义一个叫p1的Point结构体变量

p1.x = 10; //p1成员初始化
p1.y = 20;

还可以将结构体作为参数传递给函数。如下例所示(此例在定义结构体的同时定义了struct1变量并初始化):

42acc99bcb3b438b8909c396de131583.png

联合体(共用体)类型(union)

用户定义的类型,允许在同一个内存位置存储不同的数据类型。特点如下:

        1.联合体的成员共享相同的内存空间,这意味着在任何给定时间只能使用一个成员

        2.联合体可以作为一种节省内存的方式,不同的数据类型可以在不同的时间点使用同一块内存空间。但相应的,不能同时访问联合体的多个成员,因为它们共享相同的内存位置。

        3.联合体的大小由最大的成员决定,即联合体的大小足以容纳最大的成员。

联合体的定义、初始化和访问与结构体类似。特别注意,联合体只能初始化第一个成员,因为所有成员共享相同的内存空间。随后赋值给其他成员时,实际上是在覆盖第一个成员的内存空间。

例子: 

8d9cbdd78880414fbb74e51ad8f18845.png

位字段(Bit Fields)

位字段是一种数据结构,它允许为结构体或联合体成员分配特定数量的位。位字段通常用于打包数据,使得数据结构占用的内存尽可能小,这在需要节省内存或与硬件设备通信时非常有用。

#include <stdio.h>

struct BitField {
    unsigned int a : 1; //分配位数
    unsigned int b : 1;
    unsigned int c : 4;
    unsigned int d : 2; //此例总共分配了一字节(8bit)
};

int main() {
    struct BitField status = {1, 0, 0xF, 0x3}; // 初始化位字段

    // 访问位字段
    printf("a: %d\n", status.a); //结果 1
    printf("b: %d\n", status.b); //结果 0
    printf("c: %X\n", status.c); //结果 1111(转二进制)
    printf("d: %d\n", status.d); //结果 11(二进制)

    return 0;
}

复数类型

用于表示复数。使用复数类型需要包含<complex.h>头文件。

#include <stdio.h>
#include <complex.h>

int main() {
    double complex z = 1.0 + 2.0 * I;    // 定义复数变量
    double realPart = creal(z);          // 获取实部
    double imagPart = cimag(z);          // 获取虚部
    printf("Complex number: %f + %fi\n", realPart, imagPart);    // 打印复数
    double complex conj = conj(z);       // 计算复数的共轭
    double modulus = cabs(z);            // 计算复数的模
    return 0;
}

指针类型

指针就是内存地址,指针变量是用来存放内存地址的变量。

所有实际数据类型,不管是int、float、char,还是其他的数据类型,对应指针的值的类型都是一个代表内存地址的长的十六进制数。定义指针时,需要指定它指向的数据类型。

int    *ip;    // 一个整型指针
double *dp;    // 一个double型指针
float  *fp;    // 一个浮点型指针
char   *cp;    // 一个字符型指针

使用指针时会频繁进行以下几个操作:定义一个指针变量、把变量地址赋值给指针( & )、访问指针变量中可用地址值。通过使用运算符 * 返回指定地址的变量的值

bb6e91778733458b872f4dea6bb373bc.png

之后的内容会有一部分专门研究指针,故在此不再叙述。

函数类型

用于定义函数的参数类型和调用后返回的数据类型。每个函数都有一个特定的返回类型,它告诉编译器函数返回值的类型。如果函数不返回任何值,它的返回类型是 void。例子如下:

char getFirstLetter(char string[]) {
    return string[0];
}

float divide(int a, int b) {
    return (float)a / b;
}

void printCiallo() {
    printf("Ciallo!\n");
}

数组类型

用于存储一个固定大小的相同类型元素的顺序集。

int numbers[5] = {1, 3, 5, 7, 9}; //定义一个数组并初始化
float floatNum[] = {1.1, 3.3, 5.5}; //[]内若为空则数组大小就是元素的数量

int firstElement = numbers[0]; // 访问数组的第一个元素(1)
number[1] = 4; //将数组的第二个元素重新赋值(3 --> 4)

注意:定义数组时,括号[]内通常指定数组的大小(元素数量)。而访问数组元素时,括号[]内指定要访问的元素的索引(位置),索引从0开始(第一个数是0,第二个数是1,以此类推)。

之后的内容会有一部分专门研究数组,故在此不再叙述。

typedef类型

用于为类型定义一个易于使用的别名,使得代码更加简洁。

创建了一个类型别名后,这个别名就可以像基本数据类型一样使用。

typedef unsigned int PositiveInt; //给unsigned int创建了一个类型别名

typedef struct {
    int a;
    PositiveInt b; //之后即可以使用PositiveInt代替unsigned int类型
} X; //此处 X 成为了这个struct结构体类型的别名

X x1;
X x2; //可以直接使用 X 来声明变量



struct Y {
    PositiveInt c;
    int d;
};//此处 Y 是一个结构体类型,但不是一个类型别名

struct Y y1;
struct Y y2; //用(struct Y)结构体类型声明变量

指针类型限定符

const:用于指定常量指针或指向常量的指针。当const用在指针前面时,它表示指针指向的值不能被修改,不能通过这个指针来改变它指向的变量的值。

volatile:用于指定易变指针。它表示指针指向的值可能会在程序的控制之外改变。这告诉编译器不要对这个指针进行优化。

三、运算符

运算符是C语言中的基本构建块,用于执行各种操作。

   1.算术运算符

+ 加法:将两个数相加

- 减法:将两个数相减

* 乘法:将两个数相乘

/ 除法:将一个数除以另一个数

% 取模:求两个整数相除的余数

++ 自增:将变量的值加1

-- 自减:将变量的值减1

++ 和 -- 注意增减值和返回新值的顺序:

int x = 1;
int a = ++x; // 前置自增,x现在是2, a也是2
int b = x++; // 后置自增,x现在是3, b是2(即在x自增前就把值赋给了b)

//自减同理

   2. 关系运算符

== 等于:检查两个值是否相等,相等返回真,否则返回假

!= 不等于:检查两个值是否不相等,不相等返回真,否则返回假

< 小于:检查左边的值是否小于右边的值

> 大于:检查左边的值是否大于右边的值

<= 小于等于:检查左边的值是否小于或等于右边的值

>= 大于等于:检查左边的值是否大于或等于右边的值

   3.逻辑运算符

&& 逻辑与:当两个表达式都为真时,返回真,否则返回假

int a = 5;
int b = 10;

if (a > 0 && b > 0) {
    printf("Both a and b are positive.\n");
} else {
    printf("One or both of a and b are not positive.\n");
}

|| 逻辑或:当两个表达式中至少有一个为真时,返回真,否则返回假

int a = -1;
int b = 0;

if (a > 0 || b > 0) {
    printf("At least one of a or b is positive.\n");
} else {
    printf("Both a and b are not positive.\n");
}

! 逻辑非:反转表达式的布尔值,如果表达式为真,则返回假,否则返回真

int a = 0;

if (!a) {
    printf("a is zero.\n");
} else {
    printf("a is not zero.\n");
}

   4. 位运算符

& 位与:对两个整数的位进行逐位与操作

| 位或:对两个整数的位进行逐位或操作

^ 位异或:对两个整数的位进行逐位异或操作

~ 位取反:反转操作数的每一位

<< 左移:将操作数的所有位向左移动指定的位数

>> 右移:将操作数的所有位向右移动指定的位数

        (由于本人目前对二进制等数制不熟悉,故此部分先略过)

   5. 赋值运算符

= 赋值:将右边的值赋给左边的变量

+= 加法赋值:将左边的变量与右边的值相加,并将结果赋给左边的变量

-= 减法赋值:将左边的变量与右边的值相减,并将结果赋给左边的变量

*= 乘法赋值:将左边的变量与右边的值相乘,并将结果赋给左边的变量

/= 除法赋值:将左边的变量与右边的值相除,并将结果赋给左边的变量

%= 取模赋值:将左边的变量与右边的值取模,并将结果赋给左边的变量

<<= 左移赋值:将左边的变量的所有位向左移动右边指定的位数,并将结果赋给左边的变量

>>= 右移赋值:将左边的变量的所有位向右移动右边指定的位数,并将结果赋给左边的变量

&= 位与赋值:对左边的变量与右边的值进行逐位与操作,并将结果赋给左边的变量

|= 位或赋值:对左边的变量与右边的值进行逐位或操作,并将结果赋给左边的变量

^= 位异或赋值:对左边的变量与右边的值进行逐位异或操作,并将结果赋给左边的变量

        (由于本人目前对二进制等数制不熟悉,故与位相关的赋值先略过)

   6. 其他运算符

?: 三元(三目)运算符:用于简单的条件判断

condition ? Exp1 : Exp2;

若Condition为真,则执行Exp1,否则执行Exp2。

sizeof:返回变量或类型在内存中占用的字节数。sizeof返回一个size_t类型的值,该值表示对象或类型的总大小(byte为单位)。

int var = 10;
size_t size = sizeof(var); // size 存储 var 所占的字节数

size_t size = sizeof(int); // size 存储 int 类型所占的字节数

& 取地址:返回变量的内存地址

* 解引用:返回指针指向的值

int var = 10;
int *ptr = &var; // ptr 存储 var 的地址

int value = *ptr; // value 存储 ptr 指向的值(即 var 的值)

. 成员访问:用于访问结构体或联合体的成员

-> 指针成员访问:通过指针访问结构体或联合体的成员

7bed27e4fe9646e09dffca63d31b9e42.png

四、控制结构

控制结构用于控制程序的执行流程。

   1.选择结构

(1) 条件语句if

  • if
 if (condition) {
    /*...满足condition时执行...*/
 }

如果condition为 true,则 if 语句内的代码块将被执行。如果为 false,则 if 语句结束后的代码将被执行。(C 语言把任何非零非空的值假定为 true,把或 null 假定为 false。)

  • if - else
 if (condition) {
     // 当条件为真时执行的代码
 } else {
     // 当条件为假时执行的代码
 }
  • if - else if - else

此语句用于在多个条件之间进行选择并执行相应代码。

 if (condition1) {
     // 当条件1为真时执行的代码
 } else if (condition2) {
     // 当条件2为真时执行的代码
 } else {
     // 当所有条件都假时执行的代码
 }

下图是之前做的Arduino电子秤的一段判断是否肥胖的代码:

e3c41491e5424970ba7b3fe4b69464af.png

(2) switch语句

switch 语句允许测试一个变量为不同值时的情况,每个值称为一个 case,且被测试的变量会对每个 case 进行检查。如果变量的值符合某个 case,则执行其下的代码块。switch 语句中可添加一个默认情况,这称为 default 标签(可选)。

如果没有匹配case的值,但有 default,则执行 default 后面的代码块。

如果没有匹配的值,并且没有 default,则跳过整个 switch 语句。

#include <stdio.h>

int main() {
    char grade;
    printf("Enter a grade (A, B, C, D, or F): ");
    scanf("%c", &grade);

    switch (grade) {
        case 'A':
            printf("You got an A! Excellent!\n");
            break;
        case 'B':
            printf("You got a B! Good job!\n");
            break;
        case 'C':
            printf("You got a C! You passed.\n");
            break;
        case 'D':
            printf("You got a D! You barely passed.\n");
            break;
        case 'F':
            printf("You got an F! Better luck next time.\n");
            break;
        default:
            printf("Invalid grade entered.\n");
            break;
    }

    return 0;
}

需要注意:每个case末尾应该使用break跳出switch循环。若上例不使用break跳出,程序的行为将会受到 "fall through" 现象的影响。即:一旦匹配到某个 case,程序将会执行该 case 以及之后所有 case 的代码,直到遇到一个 break 语句或者 switch 语句结束。

下图为不使用break的结果:输入了C,grade变量匹配到 case 'C',然后执行了这之后所有 case 的代码。这是所谓的 "fall through" 现象。

29869c67f02540bf8828c0b3c8d075ba.png

   2. 循环结构(迭代结构)

(1) while循环

只要条件为真,while循环会重复执行一个目标语句。

这是一个简单的定时器:

#include <stdio.h>
#include <windows.h>

int main() {
    int i = 0;
    while (i < 60) {
        printf("Counter: %d\n", i);
        Sleep(1000);
        i++;
    }
    printf("1 Minute Reached.\n");
    return 0;
}

(2) do-while循环

与while的不同在于此循环先执行循环体,然后检查条件。如果条件为真,则继续重复执行循环体;如果条件为假,则结束循环。这意味着循环体至少会执行一次,即使条件一开始就不满足。语法如下:

do {
    /*...*/
} while ( condition );

(3) for循环

for 循环允许你编写一个执行指定次数的循环控制结构。

for ( init; condition; increment )
{
   statement;
}

init 会首先被执行一次。这一步允许你声明并初始化任何循环控制变量。这里是可选的(可留空)

接下来,会判断 condition。如果为真,则执行循环主体。如果为假,则跳转到for 循环后的下一条语句。

假如执行了 for 循环主体,之后控制流会跳回到 increment 语句。该语句允许你更新循环控制变量。该语句同样是可选的。

上述流程会被重复(循环主体,然后改变循环控制变量值,直到不满足condition时,for 循环终止。

这是用for循环代替while的同样的定时器:

#include <stdio.h>
#include <windows.h>

int main() {
    for (int i = 0; i < 60; i++) {
        printf("Counter: %d\n", i);
        Sleep(1000);
    }
    printf("1 Minute Reached.\n");
    return 0;
}

   3. 跳转结构

跳转结构用于改变程序的执行顺序,跳转到程序中的其他位置。

(1) break语句

用于立即退出最近的循环或switch语句。如下例break退出了最近for循环。

d20a3771fbc94586b837092d5609c884.png

(2) continue语句

用于跳过当前循环迭代的剩余部分,立即开始下一次迭代。如下例continue中断了第六次迭代。

adf1d40c18b04961995a4bd4a96e5f15.png

(3) return语句

用于从函数返回,并可以返回一个值给调用者。

int add(int a, int b) {
    return a + b;
}

(4) goto语句

用于无条件地跳转到程序中的标记( xxxx: )位置。

5b4627ceff3f484a88662c6ecd853b64.png

五、函数

函数是用来执行某个特殊功能的代码块,可以通过名称重复调用。 每个C程序都至少有一个函数,即主函数main() 。函数可以接收输入参数,执行操作,并返回结果。以下为函数定义的形式:

返回值类型 函数名(形式参数) {
   函数体
}

括号中的形参用于在函数被调用时接收传递给它的值。形参相当于函数内部的局部变量,它们在函数调用期间存在,并且在函数执行完毕后销毁。

   特点和作用

模块化:函数将程序分解为小的、可管理的部分,便于理解和维护。

参数化:函数可以接受参数,使其更加灵活和通用。

可复用:允许相同功能的代码在不同地方重复使用,避免重复编写。

抽象层:函数提供了一种抽象层,隐藏操作细节,简化复杂操作。

易维护:函数边界清晰,便于定位问题和维护代码。

    函数的声明和调用

 函数声明(函数原型)告诉编译器函数的名称、返回类型以及参数的类型和数量。函数声明允许编译器在函数实际定义之前就知道函数的类型和参数。

 注意,函数在被调用前需要先声明。除非:

  • 函数在被调用前已经被定义
  • 函数的声明在头文件中,并且该文件被#include,那么在源文件中不需要再次声明
  • 标准库中的内置函数不需要再声明

 例如下:

dd384115313b4c288e6ef0cd46e140f9.png

关于返回:应在定义函数时指定与返回值相匹配的返回类型。假如上例中形参(长和宽)为浮点型,但定义CalcRectArea函数时返回值类型为int,将导致数据的隐式类型转换,造成数据完整性丢失。

六、数组

数组可以存储一个固定大小的相同类型元素的顺序集合。

(数组的基础在 二、数据类型-->2.其他数据类型-->数组类型 处,此处不再叙述。)

   1.获取数组长度

由于数组的每个元素大小固定相同,可通过 sizeof 获取数组长度,用数组的总字节数除以单个元素的字节数,即获取数组长度。

int arr[] = {1, 2, 3, 4, 5};
int arrLength = sizeof(arr) / sizeof(arr[0]);

   2.多维数组

多维数组的声明如下,x、y、... 等为行、列、... :

type ArrayName [x][y][...];

一般只使用一维或二维数组,如下是一个二维数组的定义和访问:

float FloatArray[3][5] = {
    { 1.1, 1.2, 1.3, 1.4, 1.5 }, //初始化[0][...]
    { 2.1, 2.2, 2.3, 2.4, 2.5 }, //初始化[1][...]
    { 3.1, 3.2, 3.3, 3.4, 3.5 }  //初始化[2][...]
};
//上下两种定义方式一样
float FloatArray[3][5] = { 1.1,1.2,1.3,1.4,1.5, 2.1,2.2,2.3,2.4,2.5, 3.1,3.2,3.3,3.4,3.5 };

float x = FloatArray[1][3] //将第二行第四列(2.4)赋给x

   3.传递数组给函数

如果要在函数中传递数组作为参数,必须以下面三种方式来声明函数形参:

int array[5] = {1,2,3,4,5};

void function( int array[] ) {}
void function( int array[5] ) {}
void function( int *array ) {}

    4.从函数返回数组

C 语言不允许返回一个完整的数组作为函数的参数。但是可以通过指定不带索引的数组名来返回一个指向数组的指针。例如下:

6a03c3f8dd31498e9ee22652314dbd6a.png

一旦把arr第一个元素的地址存储在p中,就可以使用 *p、*(p+1)、*(p+2) 等来访问数组元素。下图 *(p+i) 和 p[i] 相同

20f2331925c74e38a0d1191a926f7777.png

   5.动态数组

(尚未学内存分配,故稍后再提)

七、字符串

 字符串其实就是字符型一维数组,其末尾会以 null (\0) 结尾

char str1[11] = { 'W', 'i', 'l', 'l', 'i', 'T', 'o', 'u', 'r', 't' }; //末尾会自动加'\0'
char str2[] = "WilliTourt";

上面两种定义方法效果相同。以字符数组方式定义用单引号,字符串方式用双引号。在字符串末尾不用手动输入\0字符,编译器会自动添加。

C标准库中 <string.h> 定义了一个变量类型、一个宏和各种操作字符数组的函数。

1strcpy(s1, s2);
复制字符串 s2 到字符串 s1。
2strcat(s1, s2);
连接字符串 s2 到字符串 s1 的末尾。
3strlen(s1);
返回字符串 s1 的长度。
4strcmp(s1, s2);
如果 s1 和 s2 是相同的,则返回 0;如果 s1<s2 则返回小于 0;如果 s1>s2 则返回大于 0。
5strchr(s1, ch);
返回一个指针,指向字符串 s1 中字符 ch 的第一次出现的位置。
6strstr(s1, s2);
返回一个指针,指向字符串 s1 中字符串 s2 的第一次出现的位置。

例:

35dd0ab6c3e9427e8782a44d844ac85a.png

八、指针

   1.基础

以下是一些指针基础:

指针变量:存储另一个变量的内存地址的变量

指针类型:指针可以指向不同的数据类型

指针声明:使用 * 声明指针变量

指针赋值:将变量的地址赋给指针变量

取地址运算符 &:用于获取变量的内存地址

解引用运算符 *:用于访问指针指向的内存地址中存储的

空指针:指针可以赋值为NULL,表示它不指向任何有效的内存地址

#include <stdio.h>

int main() {
    int var = 5;
    int *ptr;     // 定义指针ptr

    ptr = &var;   // 将指针指向var的地址
    *ptr = 20;    // 通过指针修改var的值

    printf("*ptr: %d\n", *ptr); // 解引用指针来获取 var 地址中的值

    return 0;
}

   2.指针运算

指针运算即与指针相关的操作,包括指针的加减法、指针的比较和指针的算术运算。指针运算在数组处理、内存管理和数据结构实现中非常有用。

(1) 指针的算术运算

指针加减涉及到将指针向前或向后移动指定数量的元素。移动的量取决于指针类型和元素大小。

(2) 指针比较

指针比较主要用于确定两个指针是否指向相同的内存位置或确定一个指针是否位于另一个指针之前或之后。使用==, !=, <, >, <=, >=来比较两个指针。

以下是通过比较指针来遍历数组的例子:

#include <stdio.h>

int main() {
    int arr[] = {10, 20, 30, 40, 50};

    int *start = arr; //指向arr[0]
    int *end = &arr[4]; //指向arr[4]
    int *ptr;

    for (ptr = start; ptr <= end; ptr++) {
        printf("Current pointed element: %d\n", *ptr);
    }

    return 0;
}

   3.指针与数组

(1) 通过指针访问数组元素

这样做通常比使用数组索引更高效,因为编译器可以优化指针操作。

(当使用指针指向一个数组中的整数时,执行ptr++会使指针向前移动到数组中下一个整数的位置。这是因为指针的增量操作会根据指针指向的数据类型的大小( sizeof(type) )来增加指针的值,从而跳过整数占用的内存空间,指向下一个整数)

(2) 数组转换为指针

其更重要的功能是,数组可以被转换成指针(指向第一个元素)。数组可作为指针来使用的特性在函数参数传递中非常有用,因为函数参数通常只能接受单个值,而不能直接接受整个数组。

通过将数组转换为指针,可以将数组的地址传递给函数,函数内部就可以通过这个指针来访问数组的所有元素。

   4.指针与函数

(1) 指针作函数参数

指针可以作为函数参数传递给函数,这允许函数直接访问和修改调用者的变量

void modifyValue(int *ptr) {
    *ptr = 20; // 通过指针修改变量的值
}

int main() {
    int a = 10;
    modifyValue(&a); // a的地址被传递给函数
    printf("%d", a); // 打印变量a的值,此时a的值被修改为20
    return 0;
}

(2) 指向函数的指针

函数指针是指向函数的指针。函数指针可以把函数作为参数传递给其他函数,或者将函数赋值给指针

void (*functionPtr)(int, int);
/*
注意:
void (*functionPtr)(int, int);
void* functionPtr(int);
前者是定义一个函数指针functionPtr,指向一个接受两个int参数并且无返回值的函数
后者是定义一个返回void指针的接受整型参数的函数
*/

void add(int x, int y) {
    printf("AddedValue: %d\n", x + y);
}

int main() {

    functionPtr = add; //把此函数指针指向了add函数
    functionPtr(10); //调用add

    return 0;
}

(3) 指针作函数返回值

函数可以返回指向动态分配内存的指针,或者返回数组的指针。由于当前未学内存分配malloc等,故稍后再提。

(4) 回调函数

将函数A(通过函数指针)作参数,传递给另一个函数B。这个函数A就是回调函数。即通过函数指针被其他函数调用的函数。

引用菜鸟教程中 @笨鸟先废 在回调函数章节的评论:

此例中,sum函数即为回调函数,并通过*shapePara函数指针由sum2调用。

   5.指针的指针

什么套娃技术(

多级指针结构对于多维数组、处理不定参数、链表等,以及创建复杂的数据结构(如树、图等)或者实现某些算法(如深度优先搜索)时非常有用,但因难度偏高故现在不提及。

并且现在我并没发现多级指针的独特用处...

   6.野指针和悬空指针

初始化指针不赋 NULL 会导致野指针。这样的指针不是空指针,但指向一片访问受限制的内存区域,无法使用它。

free() 后指针不赋 NULL 会导致悬空指针。为指针分配内存后,指针便可以指向一片合法可使用的内存,但使用 free() 释放那片内存后,指针依旧存放着那片内存的地址。这时若使用了这个指针,便会出现内存错误。

九、内存管理

内存可以分为静态内存和动态内存。所谓“动态”内存,指的是在程序运行时分配和释放的内存,而不是在编译时分配的内存。内存管理允许程序在运行时动态地分配和释放内存。C标准库<stdlib.h>提供了几种方式来管理内存,包括动态分配和堆内存分配。

   1.内存分配方式

(1) 静态内存分配

如全局变量、静态变量和局部静态变量,它们在程序编译时就已经确定了内存的分配,并在程序的整个生命周期内都存在,直到程序结束。分配的内存大小在编译时就已经确定,不能改变。

(2) 动态内存分配

在程序运行时分配内存的方式。分配的内存位置在每次分配时不同,由操作系统决定。可以根据程序运行时的实际需要来分配不同大小的内存。程序员需要手动分配和释放内存,否则可能导致内存泄漏。通常使用malloc、calloc、realloc等函数进行动态内存分配。

优缺点:

        优点

  • 灵活:可以根据程序的需要随时分配和释放内存
  • 节省内存:只分配必要的内存,避免浪费
  • 适应性:适合处理不确定大小或类型的数据,如用户输入或数据文件

        缺点

  • 复杂性:程序员需要管理内存的分配和释放,增加了编程复杂性
  • 风险:如果不正确地管理内存,可能会导致内存泄漏、野指针等问题
  • 性能开销:动态分配和释放内存通常比静态内存分配有更高的性能开销

   2.动态内存分配步骤

动态内存分配允许程序根据需要在运行时创建和销毁数据结构。以下是几个<stdlib.h>中用于动态内存分配的函数:

1void *calloc(int num, int size);
在内存中动态地分配 num 个长度为 size 的连续空间,并将每一个字节都初始化为 0。所以它的结果是分配了 num*size 个字节长度的内存空间,并且每个字节的值都是 0。
2void free(void *address);
该函数释放 address 所指向的内存块,释放的是动态分配的内存空间。
3void *malloc(int num);
在堆区分配一块指定大小的内存空间,用来存放数据。这块内存空间在函数执行完成后不会被初始化,它们的值是未知的。
4void *realloc(void *address, int newsize);
该函数重新分配内存,把内存扩展到 newsize

步骤:

分配内存

int *array = (int*)malloc(10 * sizeof(int)); // 分配一个整数数组,大小为10个整型

 注意,分配内存时应该进行强制类型转换(即使malloc返回的是void*指针)

检查分配是否成功

if (array == NULL) {
    fprintf(stderr, "Memory allocation failed.\n");
    return 1; //报错并结束程序
}

应该检查返回的指针是否为NULL,以确保内存分配成功。

使用内存

for (int i = 0; i < 10; i++) {
    *(array + i) = i; // 显式解引用指针来赋值,与array[i] = i;相同
}

通过解引用指针来访问和使用已分配的内存。

释放内存

free(array);

使用完毕后,使用free释放内存,避免内存泄漏。

malloc和calloc的区别

初始化:malloc不初始化内存,而calloc将内存中所有字节初始化为零。

参数:malloc只需要一个参数 (总字节数) 。calloc需要两个参数 (元素数量和每个元素的大小) 。

   3.内存泄漏

指程序中已分配的内存空间,在不再需要使用后,没有被正确释放或无法被释放,导致这部分内存无法被再次使用。内存泄漏可能导致程序占用的内存不断增加,最终可能耗尽可用内存,导致程序崩溃或系统性能下降。

应确保每次 calloc/malloc 都有对应的 free() 操作。

十、文件操作

文件操作涉及打开、读取、写入、关闭文件等操作。C标准库 <stdio.h> 提供了一组函数,用于执行文件操作。

   1.基本步骤

(1) 打开文件

使用 fopen() 创建一个新的文件或者打开一个已有的文件。fopen() 正常执行后返回一个文件指针,异常时返回NULL。以下是函数原型和例子:

FILE *fopen( const char *filename, const char *mode );
FILE *fptr;
fptr = fopen("file.txt", "r"); // 打开file.txt用于读取(r)

访问模式的值可以是下列值中的一个:

模式二进制文件模式描述
rrb打开已有的文本文件,允许
wwb

打开文本文件,允许

如果文件不存在,则创建一个新文件

如果文件存在,则文件原内容会被删除,重新写入新内容

aab

打开一个文本文件,以追加模式写入,在已有的文件内容中追加内容

如果文件不存在,则创建一个新文件

r+rb+ / r+b打开一个文本文件,允许读写
w+wb+ / w+b

打开一个文本文件,允许读写

如果文件已存在,则文件原内容会被删除

如果文件不存在,则创建一个新文件

a+ab+ / a+b

打开一个文本文件,允许读写

如果文件不存在,则创建一个新文件

读会从文件的开头开始,写则只能是追加模式

(2) 读取文件

使用 fgetc() 、fgets() 、fread() 等函数从文件中读取数据

fgetc()

用于从文件中读一个字符,正常读取时返回读取的字符,读取完或发生错误时返回EOF

int fgetc( FILE *fp );
FILE *fptr = fopen("file.txt", "r");

if (fptr == NULL) {
    perror("Error opening file.");
    return 1;
} else {
    int ch = fgetc(fptr);

    while (ch != EOF) {
        putchar(ch); //挨个读取文件中的每个字符
    }

    fclose(fptr);
}
fgets()

用于读取一行文本,直到遇到 \n 或 EOF 时,返回当前读取到的字符,包括换行符,若发生错误则返回NULL

char *fgets(char *str, int n, FILE *stream); //n 为字符串长度

fgets() 会从指定的文件流中读取n -1个字符(因最后还有\0占一个字符),然后复制到*str中

FILE *fptr = fopen("file.txt", "r");

if (fptr == NULL) {
    perror("Error opening file.");
    return 1;
} else {
    char buffer[100];

    while (fgets(buffer, sizeof(buffer), fptr) != NULL) {
        printf("%s\n", buffer);
    }

    fclose(fptr);
}
fscanf()

用于从文件中读取格式化的数据。正常读取后返回成功读取并赋值的字段数,发生错误或到达文件末尾则返回EOF

FILE *fptr = fopen("file.txt", "r");

int i;
float f;
char s[100];

if (fptr == NULL) {
    perror("Error opening file.");
    return 1;
} else {
    while (fscanf(fptr, "%d %f %s", &i, &f, s) == 3) { //若正常读取到3个字段
        printf("Result: %d %f %s\n", i, f, s); //则打印
    }

    fclose(fptr);
}

个人认为由于 fscanf() 对文件的解析可能出错,所以不宜使用(如文件中有像123.456.78abcde2f这样的字段,就不容易区分%d,%f和%s)。除非数据格式是已知并且规则的,那么fscanf() 可以高效地读取数据。

fread()

用于从文件流中读取数据块 (强调的是按字节读取数据) 。其中读取的数据块的大小和数量需自己定义。

size_t fread(void *ptr, size_t size, size_t n, FILE *stream);

fread() 可能更多用于读取二进制文件,故不多提。

(3) 写入文件

使用 fputc() 、fputs() 、fwrite() 等函数向文件写入数据

fprintf()

用于写入格式化的数据。成功写入返回写入的字符数,出错返回负值。原型如下:

int fprintf(FILE *stream, const char *format, ...);

类似于printf,只是输出到文件流。(若把文件流定义为stdout,就与 printf() 一样了)

fputc()

写入单个字符。成功写入返回 ch ,失败则返回EOF

int fputc(int ch, FILE *stream);
fputs()

写入字符串。成功返回一个unsigned值,发生错误返回EOF

int fputs(const char *str, FILE *stream);
fwrite()

写入数据块到文件流。fwrite() 会从内存的 ptr 地址开始,写入 size 大小的 n 个数据块到文件流。

成功写入返回写入的元素数量 (n) ,出错时返回一个小于 n 的值

size_t fwrite(const void *ptr, size_t size, size_t n, FILE *stream);

(4) 关闭文件

使用 fclose() 关闭文件,释放与文件相关的资源

注意

  • 确保文件指针在使用前已正确初始化
  • 始终检查文件操作的返回值,以处理可能的错误
  • 在完成文件操作后,确保关闭文件
  • 如果需要,使用二进制模式打开文件,以避免数据损坏

   2.示例

(中文读取有误。这可能是因为 fgets() 无法读取UTF8字符)

十一、预处理器

预处理器  (CPP) 是编译过程中的一个独立步骤,主要负责在编译之前对源代码进行预处理,即执行一些文本替换的操作。预处理器指令以#号开头

   1.预处理器指令

以下是主要的预处理器指令及其描述:

#define:定义一个宏。定义的宏可以是常量值、类型定义、代码块、函数样例等。使用它可以增加代码的重用和可读性

#undef:用于取消之前定义的宏

#include:用于包含头文件或其他文件

#ifdef#ifndef:根据是否定义了某个宏,来决定是否编译某段代码。(条件编译)

#endif:用于结束一个条件编译块

#if#else#elif:提供了更复杂的条件编译功能,可以根据不同的条件编译不同的代码

#include <stdio.h>
#include "header.h"
//尖括号用于包含标准库头文件,双引号用于包含用户自定义的头文件

#define MaxNum 50        //把MaxNum定义为50
/* ... */
#undef MaxNum            //取消之前定义的MaxNum
#define MaxNum 100       //并重新定义它为100

#ifdef MaxNum            //如果定义了MaxNum(反之如果用#ifndef就是如果未定义某某)
    #define MinNum 10    //则定义MinNum为10
#endif                   //然后结束#ifdef的条件编译块
#define WINDOWS

int main() {
    
    #if defined(WINDOWS)                // 检查是否定义了WINDOWS
        printf("这是Windows平台。\n");
    #elif defined(LINUX)                // 如果WINDOWS没有定义,检查是否定义了LINUX
        printf("这是Linux平台。\n");
    #else                               // 如果上述条件都不满足,则编译下面的代码
        printf("未知平台。\n");
    #endif

    return 0;
}
// defined() 用来确定一个标识符是否已经使用 #define 定义过

   2.预处理器运算符

宏延续运算符(\)

如果一个宏太长,单行容纳不下,则可使用 \ 换行

字符串常量化运算符(#)

用于把宏定义中的参数转化为字符串常量(从而在输出信息时使用)

例子:

标记粘贴运算符(##)

用于将两个预处理标记(tokens)合并为一个标记。预处理标记通常是编译器在预处理阶段识别的基本单位(关键字、标识符、字面量等)。

#define combine(a, b) a##b //等于ab

   3.参数化的宏

可以使用参数化的宏来模拟函数。比如:

int square(int x) {
   return x * x;
}
/*上面的函数与下面的参数化宏效果相同*/
#define square(x) ((x) * (x))

   4.预定义宏

C中预定义了一些宏,可以直接使用这些宏,不能修改它。常见的有__FILE__,__TIME__等:

十二、标准库

C标准库包含了一组头文件,这些头文件提供了许多函数和宏,用于处理输入输出、字符串操作、数学计算、内存管理等常见编程任务

当前常用的标准库有:

<stdio.h>:用于标准输入输出

<string.h>:一系列字符串操作函数

<stdlib.h>:包含内存分配、程序控制等

<time.h>:包含时间和日期函数

<math.h>:包含各种数学运算函数

--10.7完结

写这个笔记的原因主要是重邮的实验室大一招新考核...

似乎还有很多小知识点没有涉及到,但本人认为学完以上内容,已经能处理很多题目了(

之后准备学习各种算法(递归递推、排序、搜索等等),并撰写另一个优快云笔记。

再之后就是刷刷刷刷刷题了...

下面的链接是第二篇笔记:

C学习笔记 基础算法整理 (10.9 - )(正学习,持续更新中)-优快云博客

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值