点击我的视频网校,学习编程课程,或者关注我的微信公众号“编程之路从0到1”,了解课程更新

1.4进阶语法(一)

本教程视频已同步到B站: 程序员的C——实用编程,不玩虚的!

进阶语法

指针与数组

#include <stdio.h>

int main(){
    int arr[5]={1,2,3,4,5};
	
	// 依次打印数组每个元素的地址
    for (int i = 0; i < 5; i++){
        printf("p: %x\n",&arr[i]);
    }
    return 0;
}

打印结果

p: 22fe30
p: 22fe34
p: 22fe38
p: 22fe3c
p: 22fe40

由上例可验证,数组的内存空间是连在一起的,它的第一个元素地址是0x22fe30,第二个元素的地址是0x22fe34,紧随其后。因为是int数组,每个元素都需要占用4个字节空间,因此地址的间隔也是4。

指针的算术运算

#include <stdio.h>

int main(){
    int arr[5]={1,2,3,4,5};

	// 声明指针p,指向数组的首元素
    int *p = &arr[0];
	
	// 将指针变量加1,表示偏移一个单位
    printf("arr[0]=%d  address=%x\n",*p, p);
    printf("arr[1]=%d  address=%x\n",*(p + 1), (p+1));
    printf("arr[2]=%d  address=%x\n",*(p + 2), (p+2));

    return 0;
}

打印结果:

arr[0]=1  address=22fe30
arr[1]=2  address=22fe34
arr[2]=3  address=22fe38

在这里插入图片描述
同理,如果我们取数组最后一个元素的地址,然后对指向最后一个元素的指针执行减1运算,那么指针就会像前偏移,指向倒数第二个元素。

学会了指针的运算,再结合解引用,就可以使用指针遍历数组。但是千万要注意,指针偏移时不能越界,也就是说指针必须始终小于或等于数组的最后一个元素的地址,不能超过最后一个元素。

指针变量本质上就是一个32位的整型,内存地址本身也就是一个编号,因此对指针进行算术运算、比较运算都是合理的。

#include <stdio.h>

int main(){
    int arr[5]={1,2,3,4,5};

    int *p = &arr[0];

    // 使用指针遍历数组
    for (; p <= &arr[4]; p++){
        printf("%d\n",*p);
    }
    return 0;
}

打印结果:

1
2
3
4
5

当然,对于指向数组首元素的指针,我们仍然可以使用下标访问。但是一定要确认,该指针当前是否还指向数组首元素,如果你对指针做过偏移运算,那么它就不再指向首元素,这时使用下标访问,很可能导致访问越界。

#include <stdio.h>

int main(){
    int arr[5]={1,2,3,4,5};

    int *p = &arr[0];

    for (int i = 0; i < 5; i++){
        printf("%d\n",p[i]);
    }
    return 0;
}

数组变量与指针

#include <stdio.h>

int main(){
    int arr[5]={1,2,3,4,5};

    int *p = &arr[0];

    printf("p=%x\n",p);
    printf("arr=%x\n",arr);

    return 0;
}

打印结果:

p=22fe30
arr=22fe30

可以看到,实际上数组名这个变量保存的就是数组的首元素地址。但是数组变量和指向它首元素的指针变量又是完全不同的两个概念。那么数组名和指针又有什么区别呢?

  1. 类型不同。如上,变量p是指针类型,变量arr是数组类型
  2. 性质不同。p是变量,可以修改值,重新指向其他地址。arr内部保存的指针是个常量,不能修改和运算
  3. 数组类型可以使用sizeof运算,求得整个数组的内存大小,而对指针p进行sizeof运算,只能得到当前指针所占用的内存大小
  4. 数组本质上是静态的,这意味着一旦声明了数组的大小,就无法根据用户要求调整大小。指针本质上是动态的,这意味着可以稍后在任何时间点调整分配的内存大小

现在我们明白了,就算数组名和指针保存的值相同,它们也是两个完全不同的概念。但是我们知道了数组名保存的是首元素地址,那么以后就可以简化代码

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

	// 直接使用数组名对指针变量进行初始化,省略&arr[0]的写法,效果是同等的
    int *p = arr;

但是需要注意,&arr&arr[0]arr三者的值相等,但是表示的意义不同,区别主要体现在指针的类型不同上。其中,&arr[0]arr差不多等价,但是&arr则不同,它表示的是一个数组类型指针,其类型为int (*)[5]

另一个需要注意的地方是,数组的中括号下标索引[]操作实际上只是编译器提供的语法糖,它的本质仍然是指针操作。换句话说,编译器最终还是会将[]操作转换为指针操作,其转换公式如下:

*p = arr[0];
*(p+1) = arr[1];

现在,大家应该能明白上一章函数部分中,数组做函数的形式参数时,自动退化为指针是什么意思了吧。一旦将数组作为函数的参数,实际上都是将数组的首元素地址复制给了函数的形参,即使你声明的是数组类型的形参也一样。

// 形参声明为数组类型:char ch[] ,没用!
// 实际上仍然会退化为指针,编译器不允许在函数传参时,对数组内容进行复制操作,无法实现值传递
// 因此,ch实际上是一个char *类型的指针而已
void convstr(char ch[], int flags);

我们可以写个简单代码验证

#include <stdio.h>

void test(int a[]){
	// 真正的数组类型,是不能进行指针运算的
	// 因此a不是一个数组类型,它就是个指针类型
    printf("a=%x\n",a++);
}

int main(){
    int arr[5]={1,2,3,4,5};
    test(arr);
    return 0;
}

我们上面已经总结了,数组名内部的指针是个常量,不能进行运算,而test函数的形参数组a却可以++运算,说明数组做形参,自动退化为指针类型。

指针与字符串

弄清楚了指针与数组的关系,再看指针与字符串其实就水到渠成了。

#include <stdio.h>

int main(){
    // 使用字符串指针表示字符串
    char *greet = "hello, Alex";

    printf("address=%x\n",greet);
    printf("%s\n",greet);
    return 0;
}

打印 结果:

address=404000
hello, Alex

需要注意,使用字符串指针时,指针本身就表示了字符串,而不要对其进行解引用。

使用字符串指针时,要注意指向字面常量和指向字符数组的区别

#include <stdio.h>

int main(){
    char *str1 = "hello, Alex";
    char str2[] = "hello, Alice";

    str1[0] = 'f';  //报错,不可修改
    str2[0] = 'f';

    printf("%s\n",str1);
    printf("%s\n",str2);
    return 0;
}

可以看到,指针str1指向的是一个字面常量,这个字面常量和数组str2所在的内存区域是不同的,它是只读的,不能做修改。而str2是一个字符数组,里面的元素是可以修改的。

字符串的进阶

实现一个类似strlen的函数,计算字符串的长度。

#include <stdio.h>

int len(char *str){
    int i = 0;
    for (; *str !='\0'; str++,i++);
    return i;
}

int main(){
    char *str1 = "hello,Alex";
    char str2[] = "hello,Alice";

    printf("%d\n",len(str1));
    printf("%d\n",len(str2));
    return 0;
}

打印结果:

10
11

实现简单正则表达式匹配器

下面的实例来自经典图书《代码之美》,这段程序使用简单的30来行代码,实现了一个简单正则表达式匹配器,其代码之简洁优雅,可为楷模,也充分展示出了C程序的简洁高效特点。

字符含义
c匹配任意字母c
.匹配任意单个字符
^匹配字符串的开头
$匹配字符串的结尾
*匹配前一个字符的0个或多个出现
#include <stdio.h>

int match(char *regexp, char *text);
int matchhere(char *regexp,char *text);
int matchstar(int c,char *regexp,char *text);

// 创建main函数,测试match函数的功能,其返回1表示匹配成功,0表示无匹配
int main(){
    char *str1 = "+8613277880066";
    
    // 检测字符串str1是否以"+86"开头
    printf("%d\n",match("^+86",str1));
    // 检测字符串str1尾部是否包含"66"子串
    printf("%d\n",match("66$",str1));
    // 字符串str1中是否包含子串"132"
    printf("%d\n",match("132",str1));
    // 是否包含3x2样式的子串,x是单个任意字符,这里不包含
    printf("%d\n",match("3.2",str1));
    return 0;
}

// 在text中查找正则表达式regexp
int match(char *regexp, char *text){
    if (regexp[0] == '^'){
        return matchhere(regexp+1,text);
    }
    do{  //即使字符串为空也必须检查
        if (matchhere(regexp,text)) return 1;
    } while (*text++ != '\0');
    return 0;
}
// 在text开头查找regexp
int matchhere(char *regexp,char *text){
    if (regexp[0]=='\0') return 1;
    if (regexp[1]=='*') {
        return matchstar(regexp[0],regexp+2,text);
    }

    if (regexp[0]=='$' && regexp[1] == '\0') {
        return *text == '\0';
    }

    if (*text !='\0' && (regexp[0] == '.' || regexp[0]==*text)) {
        return matchhere(regexp+1,text+1);
    }
    return 0;
}

int matchstar(int c,char *regexp,char *text){
    do{   // 通配符* 匹配零个或多个实例
        if (matchhere(regexp,text)) return 1;
    } while (*text!='\0' && (*text++ == c || c == '.'));
    return 0;
}

打印结果:

1
1
1
1
0

可以看到,只有最后一个不包含,我们的测试字符串是一个手机号码,其中没有"3x2"这样格式的子串,只有一个32子串。

本例非常经典,值得大家好好学习,如无法理清逻辑,建议使用调试功能,跟踪程序的执行流程,帮助理解程序的逻辑。我们可以在match函数中打上一个断点,vscode中使用【F5】快捷键开启调试
在这里插入图片描述
在左边窗口查看变量的值,配合使用快捷键【F10】执行下一行代码,遇到函数调用时,使用快捷键【F11】进入被调用的函数中继续单步调试

最后说明一下关于,*text++的用法,这里自增运算符++的优先级高于解引用运算符*,因此实际上的运算顺序是*(text++),只是绝大多数时候都会省略括号。关于自增运算符,我们在前面的章节长篇大论的讲解了一番,并不是无的放矢,实际上++运算结合指针是很常用的用法,如仍不清楚这里*text++的值,请返回算术运算符章节复习。

指针常量与常量指针

指针常量

指针常量仅指向唯一的内存地址,一旦被初始化后,就不能再指向其他地址。简单说就是指针本身是常量。

声明格式:【指针类型】 const 【变量名】

    int n = 7;
    int l = 10;

    //声明并初始化指针常量
    int* const p1 = &n;
    p1 = &l; // 错误,无法编译!指针常量不能再指向其他地址

    // 普通指针,可以指向其他地址
    int *p2 = &n;
    p2 = &l;

声明指针常量时需要注意,星号是紧挨类型的,在之前的章节已经讲过,int* 普通类型加星号合起来才是表示指针类型,因此const关键字是修饰指针变量本身的。当我们对指针常量使用解引用符修改内容时不受影响。

	int n = 7;
    int* const p1 = &n;
    //可使用解引用符,修改指针常量所指向的内存空间的值
    *p1 = 1;	//相当于n=1

当然,也有人喜欢使用另一种风格来声明指针常量,将星号与const紧挨

	int n = 7;
    int *const p1 = &n;

常量指针

常量指针的意思是说指针所指向的内容是个常量。既然内容是个常量,那就不能使用解引用符去修改指向的内容。但指针自己本身却是个变量,因此它仍然可以再次指向其他的内容。

声明格式:const【指针类型】 【变量名】

    int n = 7;
    int l = 10;

    //声明常量指针
    const int *p1 = &n;
    *p1 = 0; // 错误,无法编译!不能修改所指向的内容

    p1 = &l; //它可以再指向其他地址

指向常量的常量指针

指向常量的常量指针,即将上述两种结合到一起,简单说就是指针自己本身是一个常量,它指向的内容也是一个常量。因此它既不能修改指向的内容,也不能重新指向新地址。

声明格式:const【指针类型】const 【变量名】

    int n = 7;
    int l = 10;

    //声明指向常量的常量指针
    const int* const p1 = &n;
    *p1 = 0; // 错误! 不能修改指向的内容
    p1 = &l; //错误! 不能重新指向新地址

程序结构与作用域

过程式、模块化的C语言程序是由多个源文件(.c文件)构成的,在每一个源文件中,都形成一个文件作用域。所谓作用域,实际上就是指有效范围。一旦离开这个源文件的范围,就相当于离开了该源文件的文件作用域。在源文件中定义函数,那么在函数之外的地方,就属于全局作用域,即使是多个源文件,只要在函数之外,那它们就都属于全局作用域,全局作用域,全局都可访问。而在函数之内的空间声明变量,那它属于局部作用域。

局部变量

局部变量是指在某个函数内部声明的变量。它有两个含义

  1. 在某个函数内声明的局部变量,不能被其他的函数使用,意即只在声明它的函数内有效。
  2. 每次调用函数时,生成的局部变量的储存空间可能都是不同的,意即局部变量在函数调用结束后,就会释放,下次调用函数,生成的局部变量又是一个新的。

还要注意一点,在函数的形式参数中声明的变量,也都是局部变量。

全局变量

与局部变量相对的概念是全局变量,它声明在所有的函数体之外。全局变量在文件作用域内可见,即从变量被声明的下一行,一直到当前文件的末尾,它都可以被直接使用,因此全局变量可以被它之后定义的所有函数访问。

需要注意一点,编译器会自动将全局变量进行零值初始化。因此在使用时,只需要声明即可。如果需要手动指定其值进行初始化,则它只能被常量表达式初始化,使用其他的变量表达式初始化是不合法的。

//全局变量(正确)
int minute = 360 -10;

//错误!!! 全局变量必须使用常量表达式初始化
int hour = minute/60;

// 访问全局变量 minute
int f(int h){
    //h 是局部变量
    return h*minute;
}

int main(){
    // 局部变量
    int day=0;
    return 0;
}

static关键字

除了局部变量和全局变量,C语言中还有静态局部变量和静态全局变量,声明时使用static关键字修饰即代表静态的意思。

#include <stdio.h>

// 静态全局变量
static int s_global;

int get_count(){
    // 静态局部变量
    static int count;
    count++;
    return count;
}

int main(){
    printf("%d\n",get_count());
    printf("%d\n",get_count());
    printf("%d\n",get_count());
    printf("%d\n",get_count());
 
    return 0;
}

静态全局变量和普通全局变量的区别不是很大,主要体现在访问权限的区别上。在C语言中,全局变量是在整个程序的生命期中都有效的,换句话说,也就是一旦声明了一个全局变量,则整个程序中都可以访问,而静态全局变量,则只在声明它的那个源文件中可以访问。静态全局变量虽然也是在整个程序的生命期中都有效,但它在其他文件中不可见,无法被访问。关于这一点的细则,在下面的extern关键字的使用中做详细说明。

静态局部变量和普通局部变量的区别就比较大了,主要有三个区别

  1. 存储位置不同。静态局部变量被编译器放在全局存储区,虽是局部变量,但是在程序的整个生命期中都存在。而普通局部变量在函数调用结束后就会被释放。从这一点上看,静态局部变量和全局变量被放在了相同的储存位置。
  2. 静态局部变量会被编译器自动初始化为零值。我们都知道普通局部变量的原则是先初始化后使用,而静态局部变量则和全局变量一样,会被自动初始化,使用时只需声明,无需手动初始化。
  3. 静态局部变量只能被声明它的函数访问。静态局部变量与普通局部变量的访问权限相同,都只能被声明它的函数使用。如上例,静态局部变量count只能被get_count函数使用,即使count变量在整个程序的生命期中都有效,其他函数也无法使用它。

说完了静态局部变量后,大家肯定疑惑,既然它只在声明它的函数中使用,那它还有什么意义呢?直接使用普通局部变量不就行了,干嘛要用它?我们知道,普通局部变量在函数每次被调用的时候都会生成一个新的,调用结束后又将它释放,如果一个函数被频繁调用,这样性能岂不是很低?因为需要不停的生成新的局部变量,然后又释放掉,然后又生成新的……但是给局部变量加上了static修饰后,函数无论被调用多少次,都不会再生成新的局部变量,始终都是复用的同一个变量,这就大大减少了对内存的操作,提升了性能。

举个生活中的例子,如果你在公司楼下有一个固定的私人停车位,那么你每天上班只需要把车停在固定的地方就好,如果你没有私人停车位,那你每天到公司楼下,都需要四处去找一个空位子停车,岂不是很麻烦,效率又低,弄不好因为找停车位导致打卡迟到。

既然静态局部变量这么好,那是不是可以滥用呢?还是回到上面的例子,如果你是公司特聘人员,一个月只需要上两天班,那么你有必要在公司楼下买一个固定的私人停车位吗?显然是没有必要的,因此当函数不会被频繁调用时,不应当考虑使用静态局部变量。

最后需要特别注意,静态局部变量会一直保存上次的值,因为它一直都存在。基于这个特性,我们通常可以使用静态局部变量做计数器,如上例,每次调用get_count函数时,对静态局部变量count自增1,打印结果如下:

1
2
3
4

静态函数
static关键字除了可以修饰变量,还可以用来修饰函数。在C++、Java等面向对象的编程语言中,都存在类似于private的权限访问控制,而C语言中的static关键字,就类似这种private,被它修饰的函数只能在当前源文件中使用,在其他源文件中无法被访问。通常来说,C语言编写的大型的模块化工程中,不需要共享的函数都应该使用static关键字来修饰。

需要特别注意,由于C语言没有命名空间的概念,它只有一个全局作用域,当你的C程序十分庞大时,存在几百上千个函数时,很难保证函数不会同名。当然,通过严格的代码规范,命名规范,可以人为的保证函数不会同名,但我们可以保证自己写的函数不会同名,却无法保证引入的外部库的函数不会和我们的函数同名。一旦函数同名了,就会形成命名冲突,这就是为什么我们看一些C语言编写的开源库时,变量名、函数命名非常的复杂,名字很长,多个单词大写或以下划线分隔,这样怪异的命名很大程度上就是为了避免命名冲突。基于此,我们编写非公开、非共享的函数时,都应当使用static修饰,以此来避免一部分命名冲突问题。static修饰的函数,只在当前源文件中可见,在另一个源文件中声明一个同名的函数,就不会产生命名冲突。

示例
编写f1.c源文件

int get_count(){
    static int count;
    count++;
    return count;
}

编写main.c源文件

#include <stdio.h>

int get_count();

int main(){
    printf("%d\n",get_count());
    printf("%d\n",get_count());
    return 0;
}

编译:gcc f1.c main.c -o main
编译、运行正常

修改f1.c,添加static修饰

static int get_count(){
    static int count;
    count++;
    return count;
}

编译报错,在main.c源文件中无法使用静态函数get_count

extern关键字

在说明extern关键字前,先来看一个示例
编写t1.c

// 全局变量
int s_global=12;

编写main.c

#include <stdio.h>

int main(){
    printf("s_global=%d\n",s_global);
    return 0;
}

编译:gcc t1.c main.c -o main
这样会直接报错: error: 's_global' undeclared (first use in this function)

这好像和我们前面说的有些不符,全局变量是在整个程序的生命期都有效的,在全局可访问的,但是现在却报错了。大家要注意前面的措辞,全局变量在文件作用域内可见,即从变量被声明的下一行,一直到当前文件的末尾,它都可以被直接使用。这里的关键就是直接使用,在t1.c源文件中是可以直接使用的,但是main.c中就无法直接使用了。

当全局变量离开了它的文件作用域后,无法直接使用,这时候我们需要另一个关键字extern来帮助我们使用它。

修改main.c

#include <stdio.h>
// 写在函数外部,表示在当前文件中的任意地方都可以使用s_global
// extern int s_global;

int main(){
	// 写在函数内部,仅在函数中使用
	extern int s_global;
    printf("s_global=%d\n",s_global);
    return 0;
}

再次编译成功,运行结果

s_global=12

在这里,extern int s_global;并不是重新声明变量的意思,它表示的是引用全局变量s_global,一定要注意,如果不存在一个全局变量s_global,是无法编译的,也就是说,使用extern来引用全局变量时,全局变量一定要存在。

extern主要是用来修饰变量的,当然也可以用来修饰函数,通常C语言中的函数都使用头文件包含的方式引入声明,但我们也可以使用extern修饰。实际上C语言中的函数声明默认都是包含extern的,无需手动指定。

//以下两种是等价的,无需手动指定extern关键字
int get_count();
extern int get_count();

小拓展
有时候我们可能会看到extern "C"这样的声明,请注意,这不是C语言的语法,也不属于C语言。有些C程序员,经常把C语言和C++语言搞混,实际上这是两种不同的语言,C也并不是很多人说的那样,完全是C语言的超集,更准确的说法应该是,C是一种独立的语言,它兼容C语言的绝大多数语法,但并不是百分百完全兼容。C除了兼容的C语言的语法,另一部分就是它独立的内容。如果不能完全清楚这两种语言的边界,就会发生语法弄混的情况。

在C++中,当需要调用纯C语言编写的函数时,通常会使用extern "C"声明,表明这是纯C语言的内容。


关注公众号:编程之路从0到1

编程之路从0到1

评论

公众号:编程之路从0到1

公众号

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×