写给前端程序员的C语言

887 阅读17分钟

这是我参与「掘金日新计划 · 4 月更文挑战」的第 3 天,点击查看活动详情

如果我还从C语言写 Hello World 写起,我估计你会疯掉。总不能所有的都要来一遍叭,所以这篇文章的顺序不会按教科书的形式来讲,而是站在前端程序员与JavaScript语言和C语言对比的角度来讲。

先从数组说起。

数组

连续空间

我们知道数组在内存里是连续的空间,但是我们如何知道它是连续的呢?

例如下面这个例子:

const arr = [1,2,3]

我们如何知道在内存中 arr[0]arr[1] 的地址是相连的呢?

我们可以C语言来看:

#include <stdio.h>

int main(int argc, char const *argv[])
{
	int arr[] = {1,2,3};
	printf("%p\n",&arr[0] ); # 0x7ffeee3d697c
	printf("%p\n",&arr[1] ); # 0x7ffeee3d6980
}

注:& 是C语言里面取地址的操作符。

我们看到地址是以十六进制的形式打印出来的,数组 arr 里的第0个元素和第一个元素之间相差了 4 个字节,那么是不是所有的间隔都是相差 4 个字节呢?我们来看下。

int main(int argc, char const *argv[])
{
	int arr[] = {1,2,3};
	printf("%p\n",&arr[0] ); # 0x7ffee120497c
	printf("%p\n",&arr[1] ); # 0x7ffee1204980
	printf("%p\n",&arr[2] ); # 0x7ffee1204984
	printf("%p\n",&arr[3] ); # 0x7ffee1204988
}

我们看到结果是显然的,这说明数组在内存中是占据连续空间的。数组中的元素是按顺序进行排列的。

这有什么用吗?似乎不影响我们搬砖🧱 ,但是我相信你理解,从C的角度看待我们从js看不到的现象是有用的。

在javascript中,数组内可以放入任意类型的元素:

const arr =  [1,'2',{'a':'b'}];

而在C语言中是不支持在数组中放入不同类型元素的,在C语言中对于数组的定义是具有相同类型的一组元素,在定义数组时我们首先要在前面规定数组中的元素类型:

char arr = {"1" , "2"};

二维数组

二维数组可以看成一个矩阵,从矩阵角度看待矩阵,我们就可以做很多的事情了,除了搬砖,我们可以用矩阵做科学计算,如果你了解R语言的话,就知道这是多么的常用了。

例如 a[3][5],可以表示一个三行五列的矩阵:

image.png

那么二维数组应该如何定义呢?

int arr[][3] = {
        {1,2,3},
        {4,5,6},
        {7,8,9},
};

行数可以不用指定,C语言的编译器会自动计算,但是列比较指定。

#include <stdio.h>

int main(int argc, char const *argv[])
{
	int arr[][3] = {
		{1,2,3},
		{4,5,6},
		{7,8,9},
	};

	printf("%d\n", arr[0][1]); # 2
}

字符串

字符串与数字

我们知道其实在计算机内部,字母也是用数字来表示的,不过这如何在C语言中体现呢?

#include <stdio.h>

int main()
{
    /* code */
    char a = 49;
    char b = '1';
    if(a == b){
        printf("相等\n");
    }else{
        printf("不相等\n");
    }
    return 0;
}

我们发现上述的结果是相等,这很奇怪,如果是在javascript中,这是不可能的。

Object.is(49,'1');  // false

结果明显会是false。但是我们可以通过字符串的 charCodeAt() 方法,得到字母所对应的ASCII码,我们将字符串 1 转换成数字再和 49 来对比一下:

Object.is('1'.charCodeAt() ,49) // true

我们发现结果其实是相等的,一点都不惊讶这种差异,因为只是C语言体现的更直接,而javascript为我们进行了封装。

但其实在C语言中,我们把字符 1 以整数形式和以字符的形式输出,得到的结果是不同的:

#include <stdio.h>

int main()
{
    /* code */
    char a = '1';
    printf("%d\n", a);  // 49
    printf("%c\n", a); // 1
    return 0;
}

其实和javascript是可以对应的,以数字输出时就是数字 1,而以字符输出时是 49

字符串计算

#include <stdio.h>

int main()
{
    /* code */
    char a = 'a';
    char b = a + 1;
    printf("%c\n", b); // b
}

大写字母转小写字母:

#include <stdio.h>

int main()
{
    /* 大写字母与对应小写字母之间的距离 */
    int distance = 'a' -  'A';
    char B = 'b' - distance;
    char S = 's' - distance;
    printf("%c\n", B); // 'B'
    printf("%c\n", S); // 'S'
}

小写字母转大写字母:

#include <stdio.h>

int main()
{
    /* 大写字母与对应小写字母之间的距离 */
    int distance = 'a' -  'A';
    char b = 'B' + distance;
    char s = 'S' + distance;
    printf("%c\n", b); // 'b'
    printf("%c\n", s); // 's'
}

而在javascript中这似乎很容易做到:

'a'.toUpperCase()  // 'A'
'A'.toLocaleLowerCase() //  'a'

当然这是因为javascript为我们封装好了API,我们直接调用当然是没问题的,但是知道一些更底层的知识又有什么坏处呢?

字符串和数组

在javascript里,字符串属于“伪数组”,因为它具有很多数组的特性。比如它具有 length 属性,可以使用数组中的一些方法进行处理,也可以进行遍历。比如像下面这样:

let str = 'abcdefg';
console.log(str.length); // 7

str.forEach(char => console.log(char)); // 3

for(let char in str){console.log(char)}; // 0 1 2 3 4 5 6 

for(let char of str){console.log(char)}; // 'a' 'b' 'c' 'd' 'e' 'f' 'g'

我们也很容易将一个字符串转为数组:

Array.from(str); // ['a', 'b', 'c', 'd', 'e', 'f', 'g']

我们看到字符串和数组真的太像了,我们甚至可以像数组那样根据下标取到字符串当中的某一个字符:

str[0]; // 'a'

那么在C语言中情况如何呢?

在C语言中,其实字符串就是数组。比如数组在内存中是占据连续空间的,而字符串同样也是,我们也可以用数组的形式来创建字符串。

我们先来看在C语言中如何使用数组来创建字符串。我们先来看下面的例子:

char s1[] = {"a","b","c"}; 

上面我们创建了一个字符型的数组,但是我们将它改造为一个字符串呢?

我们只需要在结尾加一个标识:\0

char s1[] = {"a","b","c","\0"};

这就表示是一个字符串了。我们可以看下它所占据的内存空间是不是连续的:

#include <stdio.h>

int main()
{
    char s1[] = "abc";

    printf("%s\n", &s1);    // 0x7ffeee92c76c
    printf("%p\n", &s1[0]); // 0x7ffeee92c76c
    printf("%p\n", &s1[1]); // 0x7ffeee92c76d
    printf("%p\n", &s1[2]); // 0x7ffeee92c76e
}

我们发现所占据的内存空间确实是连续的,除此之外,我们还发现字符串变量的地址指向的是数组的第一个字符的地址,这也符合我们的预期,因为数组占据的内存空间是连续的,它可以根据第一个字符依次找到后面的元素。

当然以数组形式创建的字符串,也可以像数组一样改变某个元素的值:

    char s1[] = "abc";
    s1[0] =  'a';
    printf("%s\n", s1);  // a23

C语言中的数组是可以指定长度的,当然以数组创建的字符串当然也是可以的:

    char s2[5] = {'a','b','c', 'd', '\0'};

需要注意的是最后的\0也是占据一个字节的。

字符串指针

我们当然可以像javascript一样以字面量的形式声明一个字符串:

    char *s6 = "abcde";
    printf("%s\n", s6); // "abcde"

是的,我 们需要在前面加 *,表示它是一个指针,指向一个字符串(数组)。而不能像javascript里一样,char s6 = abcde,这是错误的❌

那用指针和数组的形式创建的字符串变量,有什么区别吗?

我们从它们所指向的内存地址来看:

    char s1[] = "abc";
    char s2[] = "bcd";
    const char *s3 = "abcde";
    
    printf("%p\n", &s1); // 0x7ffeec87676c
    printf("%p\n", &s2); // 0x7ffee80c4768
    printf("%p\n", s3);  // 0x103389f92

我们发现,s3所指向的地址与s1s2相差如此之大,事实上,它们是存放在完全不同的位置的。

除此之外,使用指针声明的变量,其实是字符串常量:

    char *s4 = "abcde";
    s4[0] = 'f'; // 报错

事实上,上面的字符串变量声明相当于:

const char *s4 = "abcde"

因此我们在javascript里经常做的字符串重新赋值操作,或者先声明一个空字符串,后面再去修改的操作在C语言中是不可行的。

字符串赋值

在javascript中,这似乎很容易:

let s1 = 'fltenwall';
let s2 =s1;
s2 = 'flten';
console.log(s1); // fltenwall

我们将s1的值赋给s2,随后将s2赋以新值,但并未改变s1的值。我们知道这是因为在javascipt里,字符串是按值传递的,因此实际执行let s2 = s1时,实际是将s1所指向的值会复制一份给s2,因此它们是不存在引用关系的两个独立的值。

这时我们来看一下C语言,它是怎样的呢?

#include <stdio.h>

int main(int argc, char const *argv[])
{
	char *s1 = "abc";
	char *s2;
	s2 = s1;

	printf("%p\n", s1); // 0x10c3c4fa6
	printf("%p\n", s2); // 0x10c3c4fa6
}

我们看到,将s1赋给s2之后,它们两者所指向的地址其实是同一个。并没有产生新的字符串,只是让指针s2指向了指针s1所指向的字符串,对s1的任何操作就是对s2做的。

字符串标准库

字符串的标准库是 string.h

strlen() 字符串长度

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

int main(int argc, char const *argv[])
{
    char s1[] = "flten";
    printf("%lu\n", strlen(s1)); // 5
    printf("%lu\n", sizeof(s1)); // 6

}

可以看到sizeof()的结果和strlen()的结果是不同的,strlen表示的是实际的字符串长度,而sizeof是所有字符的长度,包括了字符串末尾的\0

strcmp() 字符串比较

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

int main(int argc, char const *argv[])
{
    char s1[] = "abc";
    char s2[] = "abc";

    printf("%d\n", strcmp(s1,s2)); // 0

}

我们看到完全相同的两个字符串比较的结果为0。

如果我们将s1修改一下:

    char s1[] = "bbc";
    char s2[] = "abc";

    printf("%d\n", strcmp(s1,s2)); // 1

发现结果为1,为什么会这样呢?直观上好像是首字母ba的距离。事实上,的确如此,我们继续看下面的例子:

    char s1[] = "abc ";
    char s2[] = "abc";

    printf("%d\n", strcmp(s1,s2)); // 32

在上面的例子中,我们在s1的末尾添加了一个空格,在前面三个字母abc都相同的情况下,s1的下一个字符是''(空字符),而s2呢?我们前面说过字符串的结尾是\0,那这里就是计算''(空字符)和\0之间的距离。而在ASCII码中,''(空格)对应的ASCII是32,而\0对应的是0,之间的距离刚好是32。

难道距离只能是正值吗?其实并非如此,我们来看下面的例子:

    char s1[] = "abc";
    char s2[] = "abc ";

    printf("%d\n", strcmp(s1,s2)); // -32

我们发现结果是-32

strcpy() 字符串拷贝

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

int main(void) {
  char s1[] = "Hello, world!";
  char s2[20];

  strcpy(s2, s1);

  printf("%p\n", &s1);  // 0x7ffee292275a
  printf("%p\n", &s2);  // 0x7ffee2922740
}

我们发现使用strcpy()进行字符串复制之后,s1s2所指向的内存地址是不同的。而此时可以修改s2的值:

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

int main(void) {
  char s1[] = "Hello, world!";
  char s2[20];

  strcpy(s2, s1);

  s2[0] = 'F';

  printf("%p\n", s1); // Hello, world!
  printf("%p\n", s2); // Fello, world!
}

strcat() 字符串拼接

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

int main(void) {
  char s1[20] = "Hello";
  char s2[] = "World";

  strcat(s1, s2);

  printf("%s\n", s1); //  HelloWorld
}

但需要注意的是,s1的空间要足够,能够容纳要进行拼接的s2的长度。

strchr() 字符串查找

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

int main(void) {
    char *s = "fltenwall";

    char *res = strchr(s, 'l');
    printf("%s\n", res);  // ltenwall
}

我们使用strchr在字符串fltenwall中寻找l,它的结果为一个指针,指向目标字符的地址。

我们也可以使用字符所对应的ACSII码中的数字来进行查找。字符l对应的ASCII码是108,因此我们可以使用108代替字符l

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

int main(void) {
    char *s = "fltenwall";

    char *res = strchr(s, 108);
    printf("%s\n", res); // ltenwall
}

如果寻找不到,结果将会是 null

    char *res = strchr(s, 'd');
    printf("%s\n", res); // (null)

指针

指针作为运算符

变量地址而不是变量值传入函数,还有一个好处。对于需要大量存储空间的大型变量,复制变量值传入函数,非常浪费时间和空间,不如传入指针来得高效。

void increment(int* p) {
  *p = *p + 1;
}

为什么需要指针?

假设我们现在有这样一个场景,就是我们想要交换两个变量的值,那么如何做呢?我们可以写一个函数,但是C语言中函数只能返回一个值,而显然不能满足我们的需求,这时候就可以使用指针,将两个指针传入,交换变量所指向的地址,也就完成了交互。

#include <stdio.h>

void swap(int *a, int *b);

int main(void) {
    int a = 5;
    int b = 6;
    swap(&a, &b);
    printf("a=%d,b=%d\n", a,b); // a=6,b=5
}

void swap(int *a, int *b){
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

在上面的例子中,我们将变量a和变量b指向的地址&a&b作为参数传入了我们自定义的函数swap,在函数中我们利用中间变量tmp辅助进行指针交换,实现了变量交换。

使用指针常见的错误

声明一个变量(不赋值)之后,马上就可以给它赋值进行使用,像下面这样:

#include <stdio.h>

int main(int argc, char const *argv[])
{
    int a;
    a = 5;
    printf("%d\n", a); // 5
}

这当然完全没有问题,但是如果声明的是一个指针呢?

#include <stdio.h>

int main(int argc, char const *argv[])
{
    int *p;
    p = 5;
    printf("%d\n", p);
}

上面的代码执行会如何呢?会报错。但是为什么呢?

我们来看一下未赋值时,指针所指向的地址:

#include <stdio.h>

int main(int argc, char const *argv[])
{
    int *p;
    int a = 5;
    int b = 6;

    printf("%p\n", p);  // 0x0
    printf("%p\n", &a); // 0x7ffee005a754
    printf("%p\n", &b); // 0x7ffee005a750

}

比较ab以及p所指向的地址,我们发现,指针p指向了一个奇怪的地址,这是因为声明指针变量之后,编译器会为指针变量本身分配一个内存空间,但是这个内存空间里面的值是随机的,即指针变量指向的值是随机的。这时不能保证这个地址是可读的,所以如果去读写指针变量指向的随机地址,很可能会导致程度报错。

如果我们修改一下代码,就可以正常使用了:

#include <stdio.h>

int main(int argc, char const *argv[])
{
    int *p;
    int a = 5;
    int b = 6;
    p = &a;

    printf("%p\n", p);  // 0x7ffee887c754
    printf("%d\n", *p); // 5
    printf("%p\n", &a); // 0x7ffee005a754
    printf("%p\n", &b); // 0x7ffee005a750

}

这就是说:我们必须让一个指针变量指向一个明确的变量之后,才可以使用指针

指针与数组

指针与const

我们可以这样将一个指针指向数组:

#include <stdio.h>

int main(int argc, char const *argv[])
{
    int a[] = {1,2,3};
    int *p = a;
    printf("%p\n", p); // 0x7ffeeb02175c
}

但问题是这个指针p,虽然指向了数组a,但是它同样可以指向别的地址:

#include <stdio.h>

int main(int argc, char const *argv[])
{
    int a[] = {1,2,3};
    int *p = a;
    int b[] = {4,5,6};
    p = b;
    printf("%p\n", p); //0x7ffee2aa8750
}

我们可以将指向数组的指针设为const

#include <stdio.h>

int main(int argc, char const *argv[])
{
    int a[] = {1,2,3};
    int *const p = a; 
    int b[] = {4,5,6};
    p = b; // error: cannot assign to variable 'p' with const-qualified type 'int *const'
    printf("%p\n", p);
}

将指向数组a的指针p设为const之后,它就不能再指向别的地址了。

不过这只是p不能指向别的地址,但是p所指向的地址的值是可以通过p改变的

#include <stdio.h>

int main(int argc, char const *argv[])
{
    int a[] = {1,2,3};
    int *const p = a; 
    *p = 10;
    printf("%p\n", a); // 0x7ffeee77975c
    printf("%d\n", a[0]); // 10
    printf("%p\n", p); // 0x7ffee278975c
    printf("%d\n", *p); // 10
}

我们可以看到,p所指向的地址没有发生改变,但是p所指向的值是可以改变的。由于数组的地址和数组第一个单元的地址是相同的,所以p所指向地址的值改变也就是数组第一个单元的值改变。

但是如果我们不希望指针可以修改它所指向的地址的值该如何操作呢

#include <stdio.h>

int main(int argc, char const *argv[])
{
    int a[] = {1,2,3};
    const int *p = a; 
    *p = 10; // error: read-only variable is not assignable
}

这表示是不能通过指针来改变它所指向的值的。但是这时候指针是可以指向别的地址的,而指向的地址的值时可以自己改变的(只是不能通过指针进行改变而已)。

#include <stdio.h>

int main(int argc, char const *argv[])
{
    int a[] = {1,2,3};
    const int *p = a; 
    int b  = 10;
    p = &b;
    a[0] = 30;
    printf("%p\n", a); // 0x7ffee4d5975c
    printf("%p\n", p); // 0x7ffee4d59744
    printf("%d\n", a[0]); // 30

}

我们可以看到,指针所指向的地址已经发生了改变,和数组的地址已经不一致了。而已数组元素的值也是可以被改变的。

所以const与指针之间的关系就有了两种:

(1)不能通过指针去修改指向所指向的地址的值

(2)指针不能指向别的地址

对于第一种情况,下面两种声明的方式是等价的:

const int* p1 = &i;

int const* p2 = &i

对于第二种方式,即指针不能指向别的地址,需要将const直接放在指针变量前面:

int *const p3 = &i;

所以我们定义一个函数,需要传入一个指针,而且我们不希望通过指针去修改所指向的值,我们可以在定义函数时,将指针参数变为const

void f(const int* x)

这样,即使传入的指向不是const,我们也可以在函数中将其变为const,这样就不会在函数内部修改传入的指针所指向的值。

数组与const

数组变量是const的指针,这意味着数组变量不能再指向其他的地址:

int a[] = {1,2,3};
a = {3,4,5}; //报错

但是数组中的元素是可以被改变的:

int a[] = {1,2,3};
a[0] = 5;

但是如果我希望数组中的元素也全部都是const呢?

只需要这样做:

const int a[] = {1,2,3};
a[0] = 5; // error: read-only variable is not assignable

如果对想改变数组中的元素,会引发报错。

如果我想添加元素呢?

const int a[10] = {1,2,3};
a[3] = 5; // error: read-only variable is not assignable

同样是不行的🙅‍♂️

这有什么用呢?如果数组变量前面加const,意味着我需要在数组初始化的时候就把元素想好,因为后期是不能进行任何修改的,这岂不是很麻烦?但它的实际用途在于:可以保护数组不被修改

因为我们将数组传入函数时,实际上传入的是数组的地址,函数内部是可以对数组进行修改的。为了保证传入函数的数组不会被修改,我们可以在定义数组的时候不加const,但是将函数参数设置为const,这样我们既保证了数组的可操作性,又保证了传入以后的数组在函数内部是不可修改的。

#include <stdio.h>

int getItem(const int a[]);

int main(int argc, char const *argv[])
{
    int a[10] = {1,2,3};
    getItem(a);
}

int getItem(const int a[]){
    printf("%d\n", a[0]); 
    a[0] = 3;  // error: read-only variable is not
    return a[0];
}