程序员C语言快速上手——基础篇(五)

基础语法

简单函数

C语言中的函数其实是多条指令的组合单元。更通俗的说就是许多语句的组合单元。函数的好处是可以让编程结构化,而不是像早期的程序那样写成一坨。另外函数可以复用代码,这使得程序员可以少写大量的重复代码,还使得大型程序可以模块化,多人同时开发。

在国内大量的C语言图书及高校的垃圾教材中,大量的例程永远只使用一个main函数,虽然这些教材也讲函数,但明显是为了讲而讲,形而上学的讲,完全缺乏实用性。有过编程经验的朋友都知道,实践工作中,C语言的函数和高级语言的类是多么重要的内容,所有的开发工作就是围绕它们展开的,因此C语言的函数内容,应当引起足够的重视。只有真正做过开发工作的人,回头重学C语言才能真正学明白,真正学以致用。

自定义函数

在上一章我们找到了英文字母大小写转换的规律,但是我们每次使用都需要去运算,显得非常麻烦,大家已经使用过很多标准库的函数,这次我们就将该功能封装成一个我们自己的函数。

#include <stdio.h>

// 定义字符转换函数
char convchar(char ch){
    if (ch >= 97 && ch <= 122){   // 小写字母范围
        return ch - 32;
    }else if (ch >= 65 && ch <= 90){  // 大写字母范围
        return ch + 32;
    }
    return -1;  // 不是英文字母,返回-1
}

int main(void){
    char str[] = "hello";
    
    // 循环遍历字符数组,当遍历到字符串结束符就停止
    for (int i = 0; str1[i] !='\0'; i++){
        printf("%c",convchar(str[i]));
    }
    
    return 0;
}

打印结果:

HELLO

我们自己编写的convchar函数功能非常简单,就是将传入的大写字母变小写,小写字母变大写。现在来看一下定义函数的格式

// 编码风格1
返回值类型 函数名(形式参数){
		函数体
}

// 编码风格2
返回值类型 函数名(形式参数)
{
		函数体
}

// 编码风格3
返回值类型 
函数名(形式参数)
{
		函数体
}

以上是最常见的三种编写函数的代码风格,由于C语言规范并不严格,还有许多奇奇怪怪的代码风格,但这里我推荐第一种,比较简洁,易于阅读,且减少代码行数,少了花括号独占的那一行,在函数较多的情况下,可以减少许多花括号的行。

需要注意,函数的返回值和形式参数都是可选的,当有返回值时,必须配合return语句返回,当函数没有返回值时,应当使用void关键字声明,注意我的措辞,是应当,而不是必须!但在我看来,任何时候都应该明确你的返回值,而不是省略什么都不写,这是C语法的缺陷,相当不严谨的地方。当然,这也是历史遗留问题,谁让C语言是编程界的老古董呢。C89中,当省略返回值时,会默认函数的返回值为int类型。以下代码是可以正常编译运行的。

#include <stdio.h>

main(){
    printf("hello world\n");
    
    return 0;
}

要注意,没有返回值时应当写上void,但没有形式参数时,可以省略,也可以写上void,不过建议省略,保持代码简洁明了。

// 无返回值,无形参
void printError(){
    printf("this is error!\n");
}

// 求和函数。形式参数的声明,与普通变量声明相似,有几个就声明几个
int sum(int a,int b,int c){
    return a + b + c;
}

调用函数

#include <stdio.h>

int sum(int a,int b,int c){
    return a + b + c;
}

int main(){
    // 函数调用,在小括号内传入实际的参数,以替换形式参数
    int r = sum(1,2,3);
    printf("%d\n",r);
    return 0;
}

函数的声明

在C语言中,出现了两个概念,声明定义。除了C/C++,在很多高级语言中,声明和定义基本是等同的,大量不了解C语言的程序员也是这么看待的,那么声明和定义到底是什么,有什么区别呢?先看下面的例子

#include <stdio.h>

int main(){
    printError();
    return 0;
}

void printError(){
    printf("this is error!\n");
}

以上代码在VC编译器等其他一些编译器会直接报错,而在GCC编译器只会报警告,仍可以编译运行。大家会发现,这是因为我们自定义的函数写在main函数之后,编译器通常从上往下扫描代码,当扫到printError()时,会发现并不认识它,有些人会想当然的认为报错是因为编译器不认识该函数产生的,而实际上是报的重定义了不同的类型错误。简单解释一下,当编译器扫描到未定义的函数时,编译器会自以为是的给你进行一个隐式声明,但是编译器并不知道函数的返回值和具体的形式参数啊,这时候它就会简单猜测一下,默认你的返回值是int,然后根据你调用函数时传的参数简单分析一下形参的类型,Ok,然后编译器继续往下扫描,这时它发现了你写在后面的printError函数,可是刚刚已经给你隐式声明了一次int printError(),结果发现你的实现是void printError(),和声明对不上,返回值类型不一致,那么你的函数实现当然不被认可,编译器认为你在重新修改函数声明。有些聪明人就想,既然这样,那我定义一个int printError()函数,它的返回值刚好就是int,这样编译器的隐式声明不就能猜对返回值了吗?不错,具有int类型返回值的函数定义,形参又比较简单,编译器确实能帮你成功的隐式声明,但这种小聪明是绝不推荐的。

那GCC为什么只警告不报错呢?这是因为GCC编译器已经是现代编译器中最强大的存在,它具有一定的代码智能优化能力,你的某些错误,它帮你兜了。但这种错误是绝不应该犯的,实际中绝不能写这样的代码。出于某些原因,我们想将函数的具体定义写在main函数之后,正确的姿势应当是怎样的呢?

#include <stdio.h>

// 在main函数之前先声明
void printError();

int main(){
    printError();
    return 0;
}

// 在main函数之后再定义
void printError(){
    printf("this is error!\n");
}

可以看到,函数声明时是不需要花括号包含的函数体的,只需要包含返回值类型、函数名、形式参数即可。但是要注意,小括号后面的分号是不能省略的。

以上示例就是将函数的声明与定义分开,在实际开发时,这些函数声明也并不是像这样直接写到main函数之前的源码中,而是写到头文件中,由于我们还没有讲到头文件,具体内容在后面的部分再说。

函数的作用域

这里简单说说作用域的问题,在函数中声明的变量被称为局部变量,只在当前函数体的范围内可被访问,离开该函数体的范围就不可被访问了。而在函数外的声明的变量,称为全局变量,全局变量在整个程序中都可被访问,也就是所有的源文件,包括.c.h文件中都可被访问。具体细节,在后续篇幅中会详细说明

简单函数的小结

  1. 函数不能返回数组,因此函数的返回值不能是数组类型。
  2. 函数没有返回值时,也应当写上void明确返回值类型
  3. C语言没有函数重载概念。这意味着C中相同作用域内的函数绝不能同名,哪怕返回值和形参都不同。C语言还没有命名空间的概念,这两者综合一起就是C语言最大缺陷之一。
  4. C语言函数的声明与定义是分离的,但是在任何时候都应当先声明再实现。这里声明是指显式声明。意即,当自定义的函数被定义在main函数之前时,它同时包含了声明与定义。

关于形式参数与实际参数的概念理解
形参就相当于是商店橱窗里的塑胶模特,而你带着女朋友进去试衣服,你的女朋友就成了实参。

C语言的实参与形参之间是值传递,简单说就是值拷贝。在调用函数传参时,实际参数的值被复制了一份,拷贝给形参。因此形参与实参本质上就是两个变量,不可等同,它们仅是值相同。就如一对双胞胎小姐姐,即使长得像,穿着相同的衣服,那也还是两个人。

改进字符大小写转换函数
这里将之前的字符大小写转换函数做改进,使之能直接转换字符串的大小写

#include <stdio.h>
#include <string.h>

/*
    当数组作为形参时,不能对其使用sizeof运算符
    flags: 值为0时,全部转小写,非0时,转大写
*/
void convstr(char ch[], int flags){
    for (int i = 0; i < strlen(ch); i++){
        if (ch[i] >= 97 && ch[i] <= 122){ 
            if(flags) ch[i] = ch[i] - 32;
        }else if (ch[i] >= 65 && ch[i] <= 90){
            if(!flags) ch[i] = ch[i] + 32;
        }
    }
}


int main(){
    char str[] = "Hello,ALICE";

    convstr(str,0);
    printf("%s",str);
    return 0;
}

打印结果:

hello,alice

这里需特别注意,在C语言中,凡是数组做函数参数时,都是引用传递,或者说是指针传递。其他基本数据类型做函数参数,包括结构体,都是值传递。网上存在很多错误的言论和资料,一定要明确,在C语言中,数组不存在值传递,这也是为什么不能对做函数参数的数组使用sizeof运算的原因所在,因为它会自动退化为指针。

简单指针

#include <stdio.h>

// 计数器
void counter(int count){
    count += 10;
    printf("count=%d\n",count);
}

int main(){
    int t = 0;
    counter(t);

    printf("t=%d\n",t);
    return 0;
}

打印结果:

count=10
t=0

如上示例中,counter函数为一个计数函数,每次调用都将传入的值加10,可是为什么在函数的外部打印t值,它的值没有发生改变呢?

什么是指针

在回答什么是指针之前,我认为应当先提问为什么需要指针?如果没有明确的应当重视的理由,大家何必花大力气学习它呢?

要说清这个问题,我觉得还是先讲故事比较好,特别是对于长期使用高级编程语言的朋友。

话说某日,李四闲来无趣,便寻张三要那小电影打发时间,由于两人关系较铁,张三毫不犹豫的将珍藏许久的经典贡献出来,发给了李四一份,李四观之精神大振,兴致盎然,便将小电影剪辑了一番,制成了鬼畜视频,又让那张三来品鉴,张三迷迷糊糊打开了硬盘上的小电影,发现没甚变化呀,忽然间张三一拍脑袋,却是糊涂了,他看的是自己本地的视频,于是连忙同意接收李四发过来的文件,兴致勃勃的欣赏起来。

这个故事中,张三发给李四的小资源是本地视频的一份拷贝,因此无论李四如何修改发给他的小视频,都不会改变张三本地的原始视频。这个道理大家都能理解,那么在上面的代码中也是同样的道理,变量t作为实参,只是将自己的值复制了一份传给形参count,因此无论如何修改变量count都丝毫不会影响到变量t

却说几日后,那李四又寻张三讨视频鉴赏,张三翻了翻硬盘,发现经典确实还有不少,可是文件大小都在好几个G到十几G不等,自己带宽也不甚给力,这还不传到猴年马月了?张三思索一阵,忽想起自己在某网盘还存有部分经典,索性将某网盘账号给了李四,任其挑选。李四得了网盘账号,如获至宝,夜不能寐,挑挑选选,又是一番剪辑,剪辑完成后,又是对网盘一番更新。张三听闻李四又剪辑出了新作品,那还了得,立刻登录网盘,滋滋有味的在线观看起来。

在新的故事中,为什么李四修改了视频,张三也能很快查看到新视频呢?我们不难发现,后续的故事中,这两人之间都没有再收发过文件,也不再是对资源的拷贝,这是对同一份资源的共享。这种共享有什么好处呢?我们最直观的感受就是,提升了效率,节省了资源。大文件的拷贝和传送都是需要花时间的,同时一份资源拷贝两份,明显占用了更多的存储空间,且当一人修改了资源时,还得再次拷贝并同步给另一人,时流程变得更复杂。

现在我们明白了生活中的道理,再看指针就非常清晰了,C语言中的所谓指针,简单理解其实就是数据在内存中的地址,就相当于以上故事中的某网盘账号,有了这个地址,我们就能找到共享的资源。我们需要C语言,需要指针,就是为了这极致的性能和效率,这是除了C/C++外的其他高级语言所不具备的。即使是号称继承自C语言的Go语言,它的指针也只是个半吊子货,远没有C指针强大。

如何理解内存

理解了抽象意义上的指针概念,接下来看看,计算机中的内存又是怎么回事?
在这里插入图片描述
在计算机中,内存就是是一片线性的连续的小格子。每个格子的大小都是1字节,且每个格子都有一个编号,这个编号就被称为内存地址。如同现实生活中的门牌号。

当我们需要往内存中存数据时,操作系统就会随机的从这一片连续小格子中给我们分配一些可用的。例如我们要将上图中左边的信息存入内存,则操作系统会根据我们的要求,给我们分配一段空间,假设给了8个小格子,那么操作系统就会将这段空间的起始地址返回给我们,如0xff0001。在存数据时,操作系统只关心两件事,一个是分配给你的起始地址,一个是分配的格子的长度。当char name[]="Bob";时,操作系统按数据的类型依次将数据往内存中存放,而数组名name即代表地址0xff0001,接下来char age = 28;,我们这里使用一个字节保存整数28,这时,变量age的地址则是0xff0001+4。在一段内存中,除了给出首地址,其他地址都是通过这种首地址+偏移量的方式来表示的。因此才说,操作系统只关心起始地址和分配的内存空间长度。如果我们在存放一个学号int num=1000;,则地址继续往后偏移。由于int类型是4字节,因此变量num需要占用四个小格子。每个小格子都存放这个32位整数中的8个位。

理解了内存的原理,那么我们就会明白,为什么几乎所有的编程语言中都指明字符串是不可变对象。如上图,字符串name存的是Bob,这就相当于一个萝卜一个坑,如果修改为Bruce,则会超出原来的格子,从而强占变量age的格子,覆盖掉了age的值,造成这一片内存区域的混乱。

指针的使用

严格的说,C语言中的指针类型,是指保存的值是内存地址的变量

    int num = 10;

    //声明指针类型变量
    int *ptr;

    //给指针类型变量赋值
    ptr = &num;

    printf("ptr=%x\n",ptr);
    printf("num=%d\n",*ptr);

打印结果:

ptr=22fe44
num=10

上例中,int *ptr;,变量ptr就是指针变量,与普通变量的区别就是多了一个星号。实际上如果换种写法大家可能一眼就理解了:int* ptr;,将int*紧挨,把它们看成一个整体,那这个整体就是指针类型,这样就复合我们最初学习的声明变量的格式了:【数据类型】【变量名】。实际上这样写是可以的,但是千万不要这样写,请将星号和变量紧挨一起,不要和类型挨在一起,虽然这很反直觉,但这确实是C语言的潜规则,当大家都这样写的时候,最好还是遵守规范。这样写并不是心血来潮,确实能避免犯一些错误。

这里还要学习两个运算符

  • 取地址运算符 &

    顾名思义,就是可以获得一个变量在内存中的地址。内存地址是一个4字节的数值,通常用16进制显示,如上例中的22fe44,它表示变量num的值储存在内存编号为22fe44的空间中。

  • 间接寻址运算符 *

    以上第10行代码中的星号是间接寻址运算符,它只能对指针变量使用,表示将该指针变量保存的地址对应的内存中的值取出来。这样说比较绕,换个说法,如果直接将一个内存地址对应的内存中保存的值取出来,这就叫直接寻址,如果是对保存地址的变量使用,这就是间接寻址。使用间接寻址运算符的过程被称为解引用
    图示

注意,指针变量的右值应当是一个地址,而不能是其他值。因此给指针变量赋值时,先使用取地址符&求得变量的地址,然后才将这个地址赋给指针变量,这个过程称为指针指向某某变量。根据指向的目标变量的类型不同,指针变量也应当声明为相应的类型。例如,指向char型变量,则应声明为char *p;。另一个重要的原则是先初始化,后使用。我们上面的例子是不规范的典型,前面我们已经说过多次,C语言中,变量应当声明后立即初始化。

那么指针变量如何在声明时初始化为零值呢?

//指针应在声明同时初始化为NULL
int *ptr = NULL;

//注意,ptr才是指针变量,而不是*ptr,切记!
ptr = &num;

如果直接访问未初始化的指针,会造成无法预知的后果,这种指针也被称为野指针!

简单指针的小结

  1. 声明指针变量时,星号应紧靠指针变量,并在同时初始化为NULL
  2. 指针变量的值应当是一个地址
  3. 声明指针类型时的星号和解引用时的星号意义完全不同,注意区分,不要混淆。声明指针类型时的星号,代表指针类型,解引用时的星号是间接寻址运算符

使用指针改进计数器示例

#include <stdio.h>

// 形参是声明,这里*表示的是指针类型
void counter(int *p){
    //此处*表示解引用
    *p += 10;
    printf("count=%d\n",*p);
}

int main(){
    int num = 10;

    // 直接初始化
    int* ptr = &num;

    counter(ptr);
    printf("num=%d\n",num);
    return 0;
}

实际上指针并不难,很多人觉得难,其实就是因此这里的星号写法设计得不合理,极易造成歧义,如果没有熟练指针的运用,那么星号总会给人别扭的感觉,时而会看错。要想快速熟练掌握指针,要学会经常画内存图,一旦发现迷糊了,赶紧画图,多画几次图,大脑建立了概念,就不会再出错。

欢迎关注我的公众号:编程之路从0到1

编程之路从0到1

猜你喜欢

转载自blog.csdn.net/yingshukun/article/details/91395364