【C语言】 重拾

【C语言】

  因为以前学过C语言,只不过太长时间不用,已经忘得差不多了… 所以这篇文章的性质是把C语言中一些对于现在的我不是很符合预期的知识点记录一下。

 ■  HelloWorld程序

  HelloWorld如下

#include <stdio.h>

int main(int argc, char *argv[]){
    int i = 0;
    printf("Hello,World\n");
    printf("i is %d\n",i);
    return 0;
}

 

  如果是在Linux上并且安装了gcc,那么将上述代码写入test.c之后,直接使用gcc -o test test.c命令就可以将其编译为可执行程序的test,随后直接./test即可执行。

  ●  注意点

  字符串一定要双引号了。printf格式化字符串末尾有必要的话记得加上\n。要指明main函数的返回类型,在最新的C语言标准C11中,规定main函数只能返回int类型,所以一定要写int main并且在main函数体的最后返回一个int型数如0。argc和argv用于接受来自命令行的参数。

  不要忘了#include <stdio.h>这个加入头文件的预处理指令。

■  数据类型

  用于表达整数的类型有char, short, int, long。没错,char算是表达整数的。这四种类型的大小(即每一个这个类型的变量占用多少字节的空间)是1, 2, 4(部分老平台可能是2), 8。知道了某个类型占据的字节数,那么就可以知道这个类型的最大最小值了。与这四者相对的,是unsigned char/short/int/long,这四种类型占据大小和前面四个一致,只不过区别在于他们将所有空间都用来表示正整数。因此,一个unsigned类型的整数可以表达的最小的数是0,最大的是将其字节全部空间都用1填充之后得到的那个二进制数。比如unsigned char最大值是255,unsigned short最大值是65535。相对的不带unsigned的类型的话,所能表示的值的范围是从(-n,n-1],左开右闭,其中2n+1等于之前unsigned类型时的最大值。比如char是(-128,127],int是(-32768,32767]。

  用于表达实数的包括了float, double, long double三种类型。具体精度不太好记了…

  void类型是指没有可用的值。通常可以用在函数返回值声明、明确函数不接受参数、指针不指向任何地址等情况。

 

■  变量声明

  声明时可以直接赋值,用逗号可以连接多个同类型变量的赋值。

  如 int a = 3,b = 5;

  在C语言中,要使用某个变量,要经历声明、定义、初始化三个阶段。习惯了python这类动态类型的脚本语言,很容易就忘记了一个中间使用,最终不返回出函数的变量的声明。

  通常,声明和定义是一起进行的比如main函数中的int i;这就是声明并且定义了变量i。如果是int i = 0; 那么就是声明、定义、初始化都一起做掉了。对于有些内容比如外部文件或者库中的变量,我们可以使用extern关键字声明,此时仅仅是声明而不涉及到定义。以下面代码为例:

#include <stdio.h>

extern int i;  // 声明变量i

int main(){
  int i;  // 定义变量i
  i = 10;  // 初始化变量i
  printf("%d",i);
  return 0;
}

 

  

  ●  关于赋值

  C语言中的表达式(变量、常量或者通过基本运算符号将它们结合后的东西),大体可以分为左值表达式和右值表达式两种。其中左值表达式指的是指向一个实际存在的内存地址的表达式,比如一个变量等;右值表达式则指那些指向内存中保存着的具体的值的表达式。

  在赋值的时候,等号左边只能是左值表达式而右边只能是右值表达式。

  int g = 10; 这个赋值操作是合法的,但是10 = 20; 就是不合法的了。 当然关于赋值这个操作,即便某一天真的反了这种错误,也是很容易看出来的。况且从人类逻辑上来说并不太会可能写出10 = 20这样的语句来…

 

■  常量

  常量即在程序运行过程中不会改变的量。通常我们常提到的“值”就是一种常量。然而在C语言中非常不同的一点在于,常量也可以有一个alias来表示。说到常量,Python中也偶尔会有类似于BASEDIR = xxx这种形式,将变量名大写来提示这个量是一个“常量”,不应该随意修改其值。但是它本质上还是一个变量,如果我想修改它的值还是可以修改。

  但是C里面的常量机制限定死了,这个常量不能修改。

  常量本身的类型以及写法(比如0x4b,科学计数法,字符串的特殊转义字符等)就不说了,主要说下常量的这个“alias”是如何定义的。简单定义常量的方式大概有两种,分别是#define宏以及const关键字。

#include <stdio.h>

#define LENGTH 10
#define WIDTH 5

int main(int argc, char *argv[]){
  int area;
  area = LENGTH * WIDTH;
  printf("area is: %d\n",area);
  return 0;
}
#include <stdio.h>

int main(int argc, char *argv[]){
  int area;
  const int LENGTH = 10;
  const int WIDTH = 5;
  area = LENGTH * WIDTH;
  printf("area is: %d\n",area);
  return 0;
}

 

  上面两个代码中,如果尝试修改LENGTH或者WIDTH的值那么就会报错,赋值给只读变量。

 

■  存储类

  存储类这个东西之前学习C语言的时候老师没有怎么仔细教。一个变量,除了可以按照数据类型对其进行分类之外,往往还可以根据其存储类型进行分类。存储类有点像java里面的从public到private的权限关键字。C语言中的存储类影响变量/函数的作用范围以及生命周期。C语言中的存储类包括:

  auto, register, static 和 extern。

  auto是一般局部变量的默认的存储类,可以不显式地声明。auto只能用在函数中,用于声明某个局部变量的存储性质。

  register声明的变量不存储在RAM中而是存储在寄存器中。

  static声明的变量存在于整个程序的声明周期中,不会随着进出作用域而创建或销毁。全局变量的默认存储类就是static。

  extern声明的变量/函数不仅在本文件中可用,在其他相关文件中也可用。在多文件共享变量/函数时常用

 

■  运算符

  一般的算数运算符,逻辑运算符就不多说了。主要说下不太常见的几个运算符

  位运算符: &,|,~,^    这四个运算符分别是与运算,或运算,非运算,异或运算。除了非运算其余都是二元运算符。异或运算是指前后的值不同时返回真,否则返回假。如0^0=0,1^1=0,1^0=1

  与或非运算本身没什么可说的。这些运算符通常直接操作数字,数字按照二进制写出来之后一位一位地比较。

  <<,>>    这两个是左移右移运算符,左边是待处理值,右边是一个数字表示移几位。比如60在8bit环境中的二进制表示是0011 1100。如果进行60 >> 2 运算,那么向右移动两位,左边用0补齐(如果原值是负数那么就要用1补齐),得到的是0000 1111,即15。相反,如果是 60 <<  2,那么得到的就是1111 0000,右端用0补齐,即240。

  上面所有位运算符如果是二元运算符,那么就都会有相应的赋值运算,如A &= B 就是 A = A & B, A <<= B 就是 A = A << B

  

  除了位运算符,还有以下几个运算符需要指出:

  & 和 *。    & 在这里是一个一元运算符,所以不是与运算的意思。用于获得某个变量的实际地址。比如&a; 可以获得a变量具体保存在内存中的地址。*也不是乘号,而是一个指向某个变量的指针,某些场合下可以认为其运算的值是内存中的地址,通过*运算符的处理返回的是相关内存地址中的实际数据值。这个符号在C语言中和稍微高级点的数据结构打交道的时候就会用到。 

  对比一下,&的运算对象是变量(或者说变量名?),计算得出的是变量在内存中的存储位置。

  

  C语言中的运算符有个比较重要的概念就是它的优先度。当然在一般的程序当中可能不太会遇到需要很细致地去辨别一堆运算符的优先度。就不单独写了。参考一下各种资料即可。

 

■  判断、循环、函数

  写久了Python就觉得switch语句实在是太棒了…

  for循环,while循环都很熟悉了。do...while...循环其实也很棒,其写法是:

do{
  // do something
}while(xxx);

 

  可以保证循环体至少被执行一次,判断条件放到每次执行结束后进行而不是开始前。

 

  ●  函数

  声明C语言函数时不要忘了声明函数的返回值类型或者是void。另外每个参数也要明确地指出类型。

  函数的参数有形参和实参的区别。可以认为形参就是该函数的局部变量,在执行完函数体之后就被销毁。需要注意的一点是,和Python中的可变类型,不可变类型类似,如果函数体对形参做出改变,就要清晰地意识到这种改变是否对实参有影响。在C语言中术语是传值调用和引用调用。

  传值调用,类似于不可变类型做参数,其过程是实际参数将自己的值传递给形式参数,然后形式参数在函数体中被做出改变,这种改变并不影响实际参数那个变量本身,因为形式参数只是实际参数的副本。

  引用调用的标志是 形参是指针,相当于是说应该传入地址,所以实参应该是一个变量的存储地址了。比如最经典的例子,编写一个swap函数交换两个变量存储的值。如果在Python中,我们可以直接 a,b = b,a这样的方式来交换值,因为a,b变量从更加低层的角度来看本身就是两个指针(引用)。

  下面是两个例子:

#include <stdio.h>

void swap(int a, int b){
  // 由于只是传值调用,局部变量a,b会被交换值,但是不影响外部调用时的实参x和y。
  int tmp;
  tmp = a;
  a = b;
  b = tmp;
}

void swap_p(int *a, int *b){
  // 由于是引用调用,指向ab的两个指针直接交换了值,原来的实参也就交换了值
  int tmp;
  tmp = *a;
  *a = *b;
  *b = tmp;
}

 

 

■  作用域

  局部变量是语句块(包括函数,循环,if/else结构等任何出现大括号对的地方)内的变量,一旦程序运行出了大括号则变量销毁

  全局变量是在函数外声明的变量,一般而言全局可用。

  变量名之间如果出现冲突,则以当前所在区域较小较局部的变量为准。一旦走出这个区域则回到高一级的同名变量。

  正如上面所说,函数的形式参数是函数体范围内的局部变量。

  对于局部变量,声明时不会自动赋值。对于全局变量,如果声明时没有初始化值,那么程序会自动给他赋值为0(所有数字类型)或者'\0'(char)或者NULL(指针类型)之类的空值。

 

■  数组

  C语言中的数组是一个连续表数据结构。默认组中每个元素的类型都一致。通过type name[size]的方式声明。如 double numbers[100]。注意这个100是len(numbers)而不是最后一个元素的下标。

  数组在声明的时候可以进行初始化赋值,赋值等号右边是一个大括号,如double numbers[5] = {1.1,2.2,3.3,4.4,5.0}; 当然此时大括号中元素个数不能超过声明的数字,否则会报越界错误。相反的,初始化数组长度没有到达声明长度,那么按照顺序,已经给出初始值的元素按照初始值初始化,未给出初始值的元素则按照默认的初始值,比如int就是0,char就是'\0'初始化。若根本没有初始化的时候,如果数组是一个全局变量,那么符合全局变量的自动初始化惯例。比如int数组的未初始化元素会被赋值为0或者0.0,另外比如NULL等等;如果数组是一个局部变量,此时数组中元素的值随编译器不同而不同。有些编译器会比较友好地进行0值初始化,有些则不会,会给为初始化的元素赋值随机值。此时的数组中的就是一些垃圾值。

  对于有初始化值的情况,也可以不写明具体的数组长度。比如 int numbers[] = {1,2,3,4,5},这样这个numebrs数组自然就被声明成长度是5的数组了。但是没有初始值的话,决不能不写明数组长度

  C语言中自然是支持进行多维数组的处理的。多维数组只要在声明的时候多加几个中括号表示维度的增加即可。比如double numbers[3][4]就代表了三行四列的二维数组。对于多维数组的初始化,等号右边有两种方案:

int a[2][1] = {
  {10},
  {20}
};

int a[2][1] = {10,20};

  这两中方案都可以构建出一个2*1的二维数组,第一行的是10,第二行的是20。换言之,如果用一个一维数组去赋值一个高维数组,那么编译器会从高维数组的第一个元素开始,优先填满一行,然后再去填下一行。按照如此规则主键赋值。

  数组的取值,可以通过经典的a[1]的形式,也可以通过*(a+1)这种指针的形式。需要说明的是,后者其实是前者的本质,而且在C语言中,程序不会对数组是否越界做出判断或者警告。因此在写C代码的时候应该时刻警惕当前下标是否已经越界,要不然可能会读写到垃圾值。

  关于数组,C语言中数组的坑还有很多很多,待把指针给说明完毕之后再继续细说。

 

■  指针

  广义上来说,指针是一个变量类型的总称,这类变量的值是另一个(指定类型的)变量的地址。C语言中的常见的类型都有相对应的一种指针类型,和使用其他变量一样,需要在使用之前进行声明。声明形式是type *pointer_name。其中type就是上面说的指定的类型。比如int *p就是指一个名为p的 int型指针。它的值就应该是一个int型变量的地址。(但是实际上C语言中并没有从语法上严格要求指针类型一定要被赋值成相应基本类型的地址,比如int *p;char c; 那么也可以做p=&c;,有的编译器可能会给出警告,但是这不影响正常编译和运行。但是应该要尽量保证指针和变量类型的一致)

  根据之前运算符的介绍,&var可以将变量var的地址给获得到。因此,如果有int *p = &var; 那么合理的情况下,var应该是一个int类型的变量。

  打印内存地址时可以使用的格式化字符串是%p,所以有:

int main(){
  int *p;
  int a = 1;
  p = &a;
  printf("%p\n",p);
  printf("%p", &a);
  return 0;
}

  这里打印出来的两个地址是相同的。

  指针类型的变量除了直接调用其变量名得到的是一个地址值外,还可以使用*p的方式获得到其所对应的地址值对应的存储块中,到底存储着什么数据。

  对于指针来说,其空值是C语言中的特殊值NULL。代表目前指针没有指向任何其他变量的地址。当指针是全局的变量时NULL也是初始化的默认值。当指针是局部变量时,如果没有指定的初始化值,那么比较好的一个习惯就是用NULL给予它初始化。如 int *p = NULL;  当指针没有被给予初始化值,那么指针会随机指向一块内存空间,而这个指针称为野指针。此时如果认为 *p是对应某个变量的值,所以做了 *p = 1; 之类的操作,那么这个操作显然是非法的。正确的操作应该是 p = &a;。

 

  ●  指针的赋值以及合理的赋值形式

  如果我们有指针int *p,变量int a=1。那么如果要让p指向a并且能够通过p也能获得到a的值即1,那么可以 p = &a;。事实上这块蛮麻烦的,下面是一些围绕a, p, 1三个要素的一些赋值以及情况说明:

  int *p; p = &a;  正确,标准的做法。

  int *p; *p = a;  编译无措,但是危险操作,不应该做

  int *p; *p = 0;  编译无错,运行报错segmental fault

  int *p; p = 0;  编译无错,但是是危险操作,不应该做

  在解释上面现象的时候首先应该要明确几点(都是我自己YY的,有可能有错)

  1、 C语言中一个变量到底是什么。粗浅来说,我们可以认为变量就是一块有结构的内存块。其内部内容大概包括了 变量名,变量地址和变量值,我们分别以v.name, v.addr和v.value表示吧。对于普通的变量int a = 1;,这三者分别是'a',addr:1000(编的,下同),1。对于int *p=&a;这个指针变量而言这三者是'p', addr:1500, addr:1000。其中所有地址值加上了addr:前缀。

  2、 再回头看看&和*两个运算符的意义。&运算符毫无疑问是取一个变量的地址。而*运算符后面如果是一个指针的时候,代码中和表达式*p对等的,并不是a.value,而是a这个整体。相应的,在代码中和 p 对等的不是a的整体或者其他的什么东西,而是&a。

  3、 指针被声明之后如果没有给予初始化值,那么指针的.value是随机的一块内存空间的地址,姑且称为野空间,处于这个状态的指针也叫野指针。这块野空间的具体结构和规格由指针的类型指定。比如int *p对应的野空间自然是int规格的。对于野空间的直接使用是应该避免的。

  如此上面的几条就好理解了一些了。*p = 0报错,因为0是一个常量,不具备0.value以及0.name等属性。虽然能赋值,但是赋值之后*p对应的野空间是不合法的一个空间(况且还是一个野空间); p = 0 属于危险操作,因为相当于是将addr:0赋值给了p.value,可能会读写一些意想不到的数据;*p = a看似没有问题,但是是将a复制了一份到了野空间,还是使用了野空间,而且*p和a是互相独立的,如果此时另a++,尝试print *p值还是不变的。

  下面继续说一下NULL:

  NULL是一个常量,其值一般是内存中的addr:0x00000000。 另外,一般系统中都把addr:0x00000000附近的空间列为系统级的空间,即用户程序是无法对其进行修改的。

  同时注意一个小点,指针如果在声明的时候初始化,那么此时的int *p相当于一般代码中的p而不是*p。

  好了,看到  int *p = NULL; 这个初始化。由于Int *p相当于p,等于说是将NULL代表的这个地址写到了p.value中去。 此时如果进行 *p = a; 或者 *p = 0; 会因为0x00附近空间不可写导致错误。总体而言,当使用了NULL对某个指针进行初始化,那么一定要按照规范的 p = &a,来将p.value直接修改成另一个合法可写的地址,避免对0x0空间做出修改而报错。

  另外再强调一下,&和*都是运算符,并不一定只能运算变量,还可以算常量。所以对于int a[5]; *(a+1)这样的操作也是可行的。

 

  ●  指针的运算

  指针可以进行+,-,++,--之类的运算。

  上文说到指针声明的时候要指明其指向的是哪一类变量的地址。而这个说明就决定了指针进行自增自减运算时的步长。比如int *p的地址位置是1000的话,那么p++之后,p的值(或者说p指向的地址)就是1004了。因为一个int类型是4个字节的大小。对应的,char *c如果进行c++,那么得到的是1001号地址。

  和它统一的还有一般的加减操作。比如还是上面的int *p,如果p原先指向的地址是1000号地址。那么p+10指向的地址不是1010而是1040。在地址加减计算中,程序为我们自动将步长乘了进去。

 

  ●  指向指针的指针

  开始麻烦起来了… 刚才说了指针在声明的时候需要指出其指向地址的变量类型。如果这个类型本身也是一个指针的话,那么本指针就是指针的指针了,姑且叫他二阶指针。

  表示方法是类似于  int **p;。  而*p在此时就是一个指针,**p才是一个int型值。 关于二阶指针的赋值稍微有点容易混淆的地方。其实我们可以把指针看成一种实现了赋值魔法方法的类,当一个地址值被赋值给指针时,它只是将这个地址值作为一个重要属性保存下来,而不是说指针就是一个单纯保存地址值的变量。了解了这一点后我们就可以知道,对于二阶指针p,进行 *p = &a; 这样的操作是不对的。*p指向的是一块野空间,没有进行适配指针的初始化,此时将变量a的地址赋值给这样一块野空间是不会自动把*p这么一个东西变成一个指针的。 正确的赋值可以是 int *tmp = &a; 然后再 int **p = &tmp; 。通过明确声明一个指针作为中间人,将变量a和二阶指针p联系起来。

  对于int **p, int *t和int a,我们考虑如下事实:

  最标准的做法,显然是 t = &a; 然后 p = &t;。这样可以做到一级一级指针准确指向到数据。但是如果奇葩一点,比如做一个 *p = &a,基于上面对于指针赋值机理的YY,可以分析如下。首先int **p将开辟一片规格是int *的野空间,而开辟int *型空间的时候又会附带开辟一个int型空间。这样相当于int **p本身有两级的野空间。 代码中的*p对应的,是第一级野空间的整体,而&a显然是一个addr:常量,所以赋值完之后并不合法。所以如果在这之后通过**p调用数值会报错segmental fault。

  然后来考虑另一种奇葩赋值,*p = t(假设此时t已经=&a)。这种情况下,**p可以取到a的正确值。但是问题在于,此时*p和t并不是同一个指针。只不过两者都指向了a。如果此时将t = &b换一个指向,再去看**p看倒的还是a的值。这种情况还是用了野空间,所以还是不规范。

 

  ●  指针作为参数

  上面提到过,比如swap交换两个变量的值,在定义形参时只有用指针作为参数才能顺利交换两者。指针作为形参时其实参可以是一个指针对象或者一个地址值,如果是后者可以认为程序自动将基于所给的地址值创建一个指针对象。

  所以可以有下列程序:

#include <stdio.h>
#include <time.h>

void getSec(int *timestamp){
    *timestamp = time(NULL);
    return ;
}

int main(){
    int a;
    int *ap;
    ap = &a;
    getSec(&a);  // 或者 getSec(ap);
    printf("%d\n",a);
    return 0;
}

 

  在加入了头文件time.h之后,就可以使用time函数获取当前的时间戳,然后赋值给指针指向的变量。因此在main函数中,我们可以通过&a直接传值给int *timestamp形参,也可以构造一个int *ap,让ap指向变量a,再直接把ap作为实参传递给int *timestamp形参。无论是哪种方式,getSec函数都可以将目前的时间的时间戳整型数赋值给变量a。

  另外值得一提的是由于数组名做变量名的变量实际上是数组头指针,所以“数组”也可以作为实参传递给 “以指针做形参”的函数。数组和指针之间千丝万缕的联系下面再谈。

 

  ●  指针作为返回值

  int * function_name(param)就是一个返回地址的函数。其实可以发现,不论声明的函数返回是哪种基本类型的指针,由于函数返回的是值而不是变量,相当于最后返回出来的都是p.value即一个地址值。当然,如果返回的地址值,所对应存储快中的数据类型和函数声明时的不一样,编译器有时会给出警告。

  值得注意的是,返回函数中的局部变量的地址没有什么意义,因为函数一旦执行结束,这些地址中存储的数据都将会被销毁。所以如果是返回函数体中某个变量的地址的话,最好能保证它有static修饰(这样函数执行结束之后仍然可以保持存在)。在返回局部变量的地址的时候,编译器不会报错但是会给出警告。

 

■  数组和指针

  C语言中的数组名,它所代表的实体并不是像Python中一样的一个列表,而是数组第一项的地址。因此对于一个数组有以下事实:

  a == &a[0]

   a[i] == *(a + i)

  当然上述a[i]的i是要小于数组长度的。另外值得注意的是,如果数组没有进行初始化,取a[0]并不会报错,虽然这是危险的。因为数组变量名本质是一个指针,a[0]的本质是也就是*(a+0),也就是说直接取存储在a地址内的值。

  声明一个数组时,同时也声明了一个指针,而且这个指针所指的空间(即数组第一个元素的空间)并不是野空间,而是可以直接拿来用的。比如我们可以直接a[0] = xxx对此空间赋值。

  在定义函数的时候,如果定义了一个形参是比如int a[],那么这个形参和int *a的效果是一样一样的。函数体内的用法也完全一样。

  上面说了很多数组和指针同质化的提示,但是两者在一些场合也不同。比如在接触数组的时候,我们经常会碰到的一个问题就是数组的长度。在C语言中没有像java中.length那样这么方便的接口。通常计算一个数组的长度要通过sizeof(a)/sizeof(a[0])这样的操作来做。假设我们有int a[5]; int *p = a;。那么sizeof(a)和sizeof(p)分别是多少?其实是20和8。8是统一的指针这一大类变量的size,这无关指针具体指向什么类型的变量。20则是4 * 5,即一个int是4,5个int的大小。

  下面看一段程序:

#include <stdio.h>

double getAvg(double a[],int size){  // 完全可以改成double *a
    int i;
    double sum = 0.0;
    for(i=0;i<size;i++){
        sum += a[i];
    }
    return sum / size;
}

int main(){
    double a[5] = {1,4,6,3,3};
    double *p = a;
    int size = sizeof(a)/sizeof(a[0]);  // 不能改成sizeof(p)/sizeof(p[0])
    printf("Average: %f",getAvg(p,size));  // 可以改成getAvg(a,size)
    return 0;
}

  总的来说,数组名基本上代表了第一个元素的存储地址,除了少数情况(比如sizeof计算的时候)。

 

  ●  高维数组和高阶指针(实验环境的编译器是gcc4.8.5,对于不同编译器可能会有不同的结果)

  试着考虑一个二维数组 int a[2][3] = {{0,1,2},{3,4,5}};

  在这里,数组名a其实代表的是一个二阶指针。相当于int **a。如果将0,1,2和3,4,5看做是一个数组的两行的话,那么其实*a就是第一行数组,*(a+1)显然是第二行数组。而*(a+2)就越界了,可能取到垃圾值。

  之所以说*a是第一行数组,而不说是第一行数组的指针,是因为sizeof(a)显然是24,但是sizeof(*a)不是8而是12,说明*a实质上等价于 int b[3]这个b。这点还需要记住。

  下面要说一个高维(二维)数组和一维数组很不同的一个地方。上面对于一维数组的说明其实可以看到,一个数组的变量名a刚声明的时候,含义是一个完整的数组。而对于一个二维数组int a[M][N]而言,常数N其实是数组a的一个固有属性。也就是说,如果C语言中有个type函数用来提示类型,那么int a[]的type(a)返回的可能是int[],而int a[M][N]的type(a)返回可能是int [][N]。

  这么说的证据之一就是在声明二维数组的时候在有初始值的情况下可以写int a[][N],但是绝对不能写int a[M][]。第一维参数省略的时候,程序会按照给出的第二维数据去套初值,套完第一行再套第二行,直到初值用完为止。但是后一种情况,不符合正常逻辑,编译器就无从得知如何初始化这个数组。

  另外一方面,假如我们编写一个函数要接收一个二维数组作为参数。在一维数组的场合,这个参数可以写成int *p或者int p[]之类的。但是二维数组,虽然它是一个二阶指针,但是不能把参数写成类似于 int **p,也不能写成int a[][]。正如上面所说,第二维长度是二维数组的固有属性。因此必须写成类似于int a[][5]或者int a[3][5]也行,但是int a[3][]不行。看下面的程序:

#include <stdio.h>

void printMatrix(int p[][5],int row,int col){
    int i,j;
    for(i=0;i<row;i++){
         for(j=0;j<col;j++){
             if(p[i][j] == 1){
                 printf("%s ","");
             }
             else{
                 printf("%s ","");
             }
         }
         printf("\n");
    }
}

int main(){
    int matrix[][5] = {
        {0,1,0,0,0},
        {0,0,0,1,0},
        {1,0,0,0,0},
        {0,0,0,0,1},
        {0,0,1,0,0}
    };
    int row = sizeof(matrix) / sizeof(matrix[0]);
    int col = sizeof(matrix[0]) / sizeof(matrix[0][0]);
    printMatrix((int **)matrix,row,col);
    return 0;
}

  * 经过检查,参数不能写int **p主要是因为gcc这个编译器将数组和指针是做了比较明确的区分的。但是如果一定要用二阶指针作为参数也是可以的 ,这主要就是需要一个强制类型转换:

  (int **)p。然而在这么转换过之后,对于取数方法也就不能简单地p[i][j]这样做了(会报错),因为p[i]等价于*(p+i),而此时的p仍然需要做强制的类型转换,因此应该写成*((int *)p + i)。关于强制转换类型的更多信息下面会提到的。对于数组、指针、二维数组的应用也可以随着使用慢慢熟悉起来,确实比较复杂…

 

■  字符串

  C语言中没有想Java或者Python那样直接的字符串类型。字符串被看成是一个结尾为\0的char类型的一维数组。因此C语言中的字符串是如此定义的:

  char s[] = {'a','b','c'};  这类集合赋值形式只能出现在初始化语句中。

  当然,每个字符串要都拆成这样的字符写也太不友好,因此也支持更加方便一点的字符串操作方式:

  char s[] = "abc";  //注意双引号的区别

  ●  字符数组和字符指针的联系与区别

  在C语言中并不存在一种字符串数据类型。一般而言,一个逻辑上的字符串可以通过字符数组or字符指针两种形式来代表。正如一般意义上的数组的数组名和(数组首)元素指针一样,两者存在着很大共性。但是由于代码中字符数组除了{'a','b'}这种形式之外还有"ab"这种形式存在,使得字符数组在初始化完成之后还有机会作为一个整体对其作出改变,所以区别于其他类型的数组&数组首元素指针,字符数组和字符指针之间还存在一些应用上的差别。

  首先我们需要明确,形类于 char s[10] = "abc"; 的声明得到的s是一个字符数组。其长度可以指出或不指出。不指出时一定要带上初始值否则无法判断数组该申请多大的内存空间。这个声明出现的时候程序会申请一块指定大小的内存空间,并以s指代这一整块空间。

  对应的,形如 char *s = "abc"; 的声明得到的s是一个字符指针。这个声明的操作是在内存中申请一块可以保存"abc"这个值的内存空间,然后将内存空间的首地址赋值给s这个指针变量。

  基于上述认识,我们可以说出两者存在以下区别(部分同样适用于其他类型的数组):

  1. char s[20]和char *s两者本身大小不同,用sizeof函数可以看出。自然他们占用的内存空间也是不同的,因为都不是同一种东西

  2. 对于char s[20],s的本质是一块存储空间首地址,可以视为一个常量。在s的生命周期中,这个值是不可改变的。因此做类似于s + 1之类的操作是错误的。

  相反,char *s是一个地址指针,可以轻松地做s + 1。

  3. 未给出初始化数据时,若s是一个数组,是个地址常量,所以可以做s[1] = 'a'这样的操作。相反,char *s作为一个指针,s[1] = 'a'是危险的。

  4. 由于char s[20]作为一个数组时,s是一个地址常量。所以除了初始化的时候,之后就不能再进行s = "hello";之类的赋值。相当于对常量赋值。

 

  上面两个声明我都没有显式地指出char数组的长度,因此可以写任意长度的字符串赋初值。如果采用显式的带有数组长度的声明,那么和普通数组一样,对于未被赋予初始值的元素会被赋值为\0。

  C语言中的字符串还有一点很麻烦的就是结尾的处理。在赋值的时候,比如char s[] = "Hello"或者char s[] = {'H','e','l','l','o'};, 赋值完成后如果使用sizeof函数查看s的长度,看到的往往是6,也就是比字符串本身长1个单位。这是因为字符串在声明的时候会自动将'\0'这个字符串终止符作为尾巴添加在字符串末尾(对于第二种声明方式,有些编译器不会自动加'\0'。此时就比较危险)。

  保证一个字符串的末尾始终是'\0'字符有很多意义,其中一个就是打印字符串时,使用%s这个格式化字符串的时候。printf的格式化字符串中如果有%s,那么其行为就是以给出的char *s指针作为起始点,不断地一个个读取字符,直到读取到'\0'。如果不在我们的字符串末尾加上'\0',就很有可能会看到一些垃圾值。

   C语言内置了一些字符串处理函数,只需要引入头文件string.h便可使用:

  strcpy(s1,s2)  将s2字符串整个赋值给s1。这种赋值是一种深拷贝。由于C语言中字符串是char类型的数组,所以其应该有一个固定的声明长度。如果s1长度本身就大于s2倒还好说,如果反过来,那么就可能会出现s2赋值超出s1的范围?

  strlen(s)  返回字符串s的有效长度。注意这个有效长度的意思是说s从头开始,一直扫描到第一个\0时的长度。

  strcat(s1,s2)  将字符串s2接续到s1的末尾。同样需要注意长度、越界等问题

  strcmp(s1,s2)  字符串比较函数。如果s1和s2相同则返回0。根据ascii码大小逐字符比较并返回1或-1表示s1>s2或s1<s2。

  strstr(s1,s2)  返回s2作为子字符串第一次出现在s1中的位置指针。

  strchr(s1,char)  返回char在字符串中第一次出现的位置指针。

 

 

■  结构体

  都说C语言是一种面向过程的语言,但其实在其身上已经可以看到OOP的雏形。这就是结构体这种东西了。以OOP常用的例子,书本类作为例子。在C语言中定义一个书本结构体:

struct Books{
    char title[50];
    char author[20];
    char comment[100];
    int book_id;
};

  在这个语境下,struct Books和int, char一样,属于一种数据类型。可以调用sizeof(Books)查看一个结构体的实例占据多少空间。而使用这个实例(包括实例中的各个属性)就是和其他语言一样使用小圆点就可以了。下面是一段使用实例的程序:

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

struct Book{
    char title[50];
    char author[20];
    char comment[100];
    int book_id;
};

void show_bookinfo(struct Book);

int main(){
    struct Book book;
    strcpy(book.title,"To kill a mocking bird.");
    strcpy(book.author,"Happer Lee");
    strcpy(book.comment,"A very good book.");
    book.book_id = 1022;
    show_bookinfo(book);
    return 0;
}

void show_bookinfo(struct Book book){
    printf("[%d]title: %s\n",book.book_id,book.title);
    printf("Author: %s\n",book.author);
    printf("Comment: %s\n",book.comment);
}
View Code

  *注意到C语言中对于字符串不能直接赋值。因为字符串变量本质是char数组,因而其变量名是一个char *类型地址。

  和其他基本类型一样,所有自定义的结构体(or类)也有各自对应的指针。声明方式就是类似于struct Book *bookp这样子。除了一般的指针的用法,自定义结构体的指针还有一个比较方便的作用,就是可以直接调取其指向的实例的各个属性。使用的运算符是 ->。

  比如将上面代码修改成:

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

struct Book{
    char title[50];
    char author[20];
    char comment[100];
    int book_id;
};

void show_bookinfo(struct Book *);

int main(){
    struct Book *bookp;
    struct Book book;
    bookp = &book;
    strcpy(book.title,"To kill a mocking bird.");
    strcpy(book.author,"Happer Lee");
    strcpy(book.comment,"A very good book.");
    book.book_id = 1022;
    show_bookinfo(bookp);

    return 0;
}

void show_bookinfo(struct Book *p){
    printf("[%d]title: %s\n",p->book_id,p->title);
    printf("Author: %s\n",p->author);
    printf("Comment: %s\n",p->comment);
}
View Code

 

 

  ●  位域结构体

  刚才上面的结构体中,所有成员属性的类型都是一般的数据类型。比如int,char []等。当然也可以在结构体的属性中包含结构体。比如struct Class中包含struct Student。

  同时,C语言还支持在结构体中设置位域的成员属性。所谓位域,就是指把C语言中对内存空间最小操作单位由C语言封装好的各个类型,降低为一个个字节。比如我在struct Book结构体中想要加入一个是否是中文书的字段is_chinese。显然这个字段取值一般只会有0和1,如果将其规定为int类型,显然会浪费比较大的空间(int有4个字节32位大小)。

  此时可以将这个字段设置为  int is_chinese:1;。通过冒号加上一个数字,指出这个字段只占据一位即可。这样可以节省大部分空间。当然作为一个基本类型还是int的数据类型,冒号后面的数字必须小于等于32(4*8),否则编译器会报错。

  关于位域,还有一个比较有意思的用法,就是在结构体中固定空出一片空间。比如下面这个位域:

struct s{
    int a:1;
    int   :2;
    int b:1;
}

 

  存在一个无名的位域,因此我们无法调用。但是这片两位的空间却又是实实在在存在的,因此这个结构体中就固定会有一片空出来的空间。

  这么做的目的可能是防止属性间跨属性地修改数据或者其他一些考虑。

 

  ●  配合typedef关键字

  typedef可以理解为一种整合复杂C语言类型描述的办法。比如我们经常使用unsigned int这类类型来表达字节,所以可以在C语言文件最开始的时候进行 typedef unsigned int BYTE。在之后的程序中,我们就可以直接使用BYTE来声明unsigned int类型的变量。为了提示typedef出来的类型是我们人为自定义的,通常会将类型名全大写。

  上面的程序中,我们定义了struct Book。但是实际使用过程中,声明Book类型变量时还是写了struct Book,有点令人费解。所以在定义的时候,可以采用:

typedef struct Book{
    char title[50];
    char author[20];
    char comment[100];
    int book_id;
    int is_chinese:1;
} Book;

 

  这样在后续的程序中就可以直接将Book作为类型名直接使用。需要指出的是上面的typedef struct Book可以直接改成typedef struct,相当于指出这个结构体本身是个匿名结构体。但大括号后面的Book是它的alias名,不能省略。

 

 

■  输入输出

  ●  printf和scanf

  printf函数上面用了很多了。scanf函数用于从stdin(键盘)获取数据并进行格式化。用法如scanf("%d",&a); 其中a是一个特定变量的地址。相当于是说,scanf的作用是接受输入流的一个片段(以空白字符为界),将这个片段以第一个参数格式化字符串给出的格式转化成相应格式的数据。然后再将这些数据给存入到第二个参数给出的地址中。

  由于是以空白字符为界的,所以当输入是"abc def"的时候,如果是scanf("%s",s);,那么字符串变量s的值就会是"abc",而不是"abc def"。另一方面,scanf("%s %s",s1,s2),如果输入还是"abc def",此时可以将abc和def分别赋值给s1和s2。此时先读取到了buffer中的第一片段abc,赋值给第一个%s对应的变量。由于还存在第二个%s,所以继续向后扫描,扫描到def后赋值给第二个%s对应的变量。

  另外scanf是扫描stdin的buffer,只有当buffer中没有内容时才会阻塞stdin,达到python中类似于raw_input那样的效果。如果buffer中还残存内容,那么scanf就会自动读取那些内容,即使这些内容是上一次扫描剩下的。

 

  ●  putchar和getchar

  如果你试图从stdin中获取到一个字符,使用scanf函数可以是scanf("%c",&c);。c是一个声明为char类型的变量。这样一个过程可以用getchar一个函数来表示。

  如 char ch;  ch = getchar();  这样可以将stdin中下一个字符读入并且赋值给ch变量。

  类似的,putchar函数接受一个字符类型数据(变量或常量),并且将其输出到stdout中。定义是 void putchar(int);

  由于C语言中字符本质上都是通过ascii码规则映射的整型数,所以上述函数传值or取值符合规定的整型数在语法和逻辑上也是正确的。

 

  ●  puts和gets

  这两个函数的声明分别是  int puts(char *);  char *gets(char *);

  其作用和putchar,getchar类似,只不过针对的操作对象是字符串。gets在一些Linux环境编译器中被认为是危险操作,编译时会给出一些警告。

  Linux中可以使用fgets和fputs函数代替这两个。这两个函数的用法是:

  fgets(string, 10, stdin);

       fputs(string, stdout);

  前者的10表示只从stdin中读取10个字符,借此可以控制不越界赋值字符串。一般可以fgets(string,sizeof(string),stdin);

 

posted @ 2018-08-10 08:22  K.Takanashi  阅读(465)  评论(0编辑  收藏  举报