文章目录

  • 9. C语言真正的灵魂——指针
    • (1). 指针到底是什么?
    • (2). 指针的基本结构
    • (3). 取地址(&)和解引用(*)操作符
    • (4). 指针有什么用呢?
      • #1.swap函数的例子
      • #2.只传值的C语言函数
      • #3.总结
      • #4.其他语言一瞥
    • (5). 令人头疼的多重指针
    • (6). 数组和指针的关系
      • #1.指针的加减运算
      • #2.连续的内存
      • #3.传入数组的函数
      • #4.返回数组的函数
      • #5.一些运算符的优先级
      • #6.以指针的方式访问数组
      • #7.为什么数组下标要从0开始?
      • #8.数组指针与指针数组
    • (7). 动态内存分配
      • #1.从malloc说起:
      • #2.改进后的calloc
      • #3.灵活伸缩的realloc
      • #4.多维数组和malloc函数
      • #5.健忘的人们和迷茫的人们
    • (8). 等会儿,void* 是个啥?
    • (9). 字符串去哪儿了?
      • #1.我们见到的字符串
      • #2.自定义的字符串
    • (10). 字符串/数组的更多操作
      • #1.strlen()
      • #2.strcpy()
      • #3.strcat()
      • #4.strcmp()
      • #5.memcpy()
      • #6.memset()
    • (11). 现在我们就可以说说int argc和char* argv[]是什么东西了
    • (12). 自由的指针和不自由的程序员
    • (13). 关于“地址”的思考——我们能不能做个游戏修改器(CE)?
    • (14). 关于函数指针
    • 小结

9. C语言真正的灵魂——指针

  我想大家就算可能没有学过编程语言,也应该听说过指针的大名,指针的确是C语言中最重要的概念之一了,再我们具体讲完这一章之后,我相信你也会对我说的这句话有自己的理解。

(1). 指针到底是什么?

  有这么一件事情,AAA路上有两三百个商铺,而其中有 一家大型连锁奶茶店“好茶” 在这条街的头和尾各开了一家“好茶”,有一天你来逛街,朋友说:“去好茶买杯奶茶吧!”你表示认同,不过这条街上有两家好茶,要去哪一家呢
  我当然不是来让你考虑去哪一家的,只是我们可以把这个事情类比成C语言中的一些东西,我们来看下面这个程序:

#include <stdio.h>
int main()
{int a = 1;{int a = 2;printf("a = %d\n", a);}printf("a = %d\n", a);return 0;
}

  它的运行结果如下:

  他们都叫做a,但是值不一样,就像是我们刚说的那条街上的两家店,虽然名字一样,但是他们肯定不是同一家店,那我们有什么办法区分它们吗?在现实中,我们可以用地址来区分它们:AAA路0100号好茶AAA路3300号好茶,所以在C语言中对于变量我们也有这样的地址,这个地址就是变量在内存中的具体位置,而存储地址的变量,就称为指针(pointer)
当然,指针并不一定都用在相同名字的变量上面,只是这个例子更加有助于我们理解“变量的位置”这个概念。我们也可以把指针想象成是一张写着地址的纸。

(2). 指针的基本结构

  要声明一个指针变量还是比较简单的,只需要在各种数据类型后加一个*就好了,例如下面的三种写法:

int* p0;
double * p1;
float *p2;

  从语言的角度来看,他们没有任何区别,不过我更推荐使用int* p的格式,将int* 看作int型指针这样一个新的数据类型
  不过就声明一下也没用啊,总得给它赋个值是吧,我们之前说指针是用来存储变量的地址的,所以每个指针都需要依托一个已经存在的值、新定义的值或是NULL,NULL是C语言中定义的一个宏,它代表空指针,我们在上面声明的三个指针都是仅声明的状态,就如同我们仅声明一个变量一样,这个指针会指向一个不确定的随机位置,我们将这种指向不确定位置的指针叫做“野指针”。所以说,如果没有明确指针p应该指向什么对象的时候,在声明的时候就应该将他们置为空指针:

int* p0 = NULL;
double * p1 = NULL;
float *p2 = NULL;

  说了这么多还是没有回答这个问题,假设有一个确定的变量a,我们怎么使p指向a呢(如果p存储的是a的地址,我们就称p是指向a的)?这就需要用到 取地址操作符(&) 了。

(3). 取地址(&)和解引用(*)操作符

  有点眼熟的嗷这个&,我们最早说的用户输入好像就用到了这个:

#include <stdio.h>
int main()
{int a = 0;scanf("%d", &a);printf("a = %d\n", a);return 0;
}

  没错,这个&正是我们要说的取地址操作符。我们可以用&连接一个变量名来取得这个变量名的地址,到这里我们就可以回答上一节的问题了:

#include <stdio.h>
int main()
{int a = 12;int* p = &a;return 0;
}

  这样一来我们就给p赋上了a的地址,这样一来p就指向a了。不过这段代码啥也不会做,对于一个地址,%p可以作为它的格式化占位符,我们把p打印出来试试看:

#include <stdio.h>
int main()
{int a = 12;int* p = &a;printf("p = %p\n", p);return 0;
}

  它打印出了一串十六进制的数字,这就是变量a的地址,当然你的运行结果可能跟我完全不一样,这是正常的,我们不能保证每个人运行的时候变量都存储在同一个地址上,甚至可能在同一台机器上运行几次的结果都是不一样的。
  看到这一串十六进制数字,你可能想自己打一串上去,然后指定C语言去帮你读写这个地址,完全没问题,这是你的自由,不过如果你尝试的数字太小,很有可能会出现异常,至于为什么,我会在第十二节中提到。
  那让我们再回到第一节的代码,两个a位于不同的作用域,如果在内外分别用printf打印出值来他们也是不一样的,他们的地址是不是也不一样呢,我们来试试看:

#include <stdio.h>
int main()
{int a = 1, *p1 = &a;{int a = 2, *p2 = &a;printf("outer a = %p\n", p1);printf("inner a = %p\n", p2);printf("a = %d\n", a);}printf("a = %d\n", a);return 0;
}

  他们的值不一样,地址也不一样。在C语言中,还有一个与取地址相反的操作,是取某个地址对应的值,这就是解引用操作符(*)

#include <stdio.h>
int main()
{int a = 1, *p1 = &a;{int a = 2, *p2 = &a;printf("outer a = %d\n", *p1);printf("inner a = %d\n", *p2);}return 0;
}

  很轻松的,我们就访问到了内部和外部两个a的值。那么很自然的,我们会想到下一个问题,指针到底有什么用呢?

(4). 指针有什么用呢?

#1.swap函数的例子

  再回到之前提过的swap函数的例子,我们想要交换变量a和b的值,最初我们只能选择通过赋值的方式来改变,在我们了解了指针之后,我们可以改变一下自己的思路了,我们可以把swap写成这个样子:

#include <stdio.h>
void swap(int* a, int* b);
int main()
{int a = 12, b = 31;printf("a = %d, b = %d\n", a, b);swap(&a, &b);printf("a = %d, b = %d\n", a, b);return 0;
}void swap(int* a, int* b)
{int temp = *a;*a = *b;*b = temp;
}

  成功了!我们没有在main里面对a和b做任何的赋值操作就完成了交换a和b的操作,那为什么我们要这么做呢?

#2.只传值的C语言函数

  如标题所说,C语言的所有函数的参数都只参数对应的值进来,你可以认为是传入了一个值,然后在函数的作用域中再创建了一个同名的变量把这个值存起来,这才使得我们在指针之前做的swap函数没有办法交换两个变量的值,但指针是某变量的确定位置,就像是送一封信给小明送一封信到BBB路3213号XXX小区0x1001号楼503室的区别一样。
  由此就产生了这个问题:仅仅把变量名传入是没有办法对变量进行修改的。不过,传地址的值进去就没问题了,我们直接对地址读写就好了。

#3.总结

  因此指针带来的一个最大好处是:可以在函数体内改变函数体外声明的变量的值了(不用在函数体外对变量赋值)

#4.其他语言一瞥

  我的确是很喜欢C++哈哈哈哈哈,来看看C++中是如何应对函数内改变变量值的需求的:

#include <iostream>
void swap(int& a, int& b);
int main()
{int a{23}, b{31};std::cout << "a = " << a << ", b = " << b << std::endl;swap(a, b);   std::cout << "a = " << a << ", b = " << b << std::endl;return 0;
}
void swap(int& a, int& b)
{int temp{a};a = b;b = temp;
}

  它也完成了交换的工作,不过参数类型好像有点不一样,这里写在类型后、变量名前的&可不是取地址操作符,这是C++中引入的 “引用类型” ,引用类型是一种指针常量,即int* const p;(p不能改变,但*p可以发生改变),通俗来说就是给变量起了一个别名,然后调用别名也相当于调用本体了。这样做的好处是:减少指针的使用,指针的自由性给程序员带来了很多方便,但是也会因为各种各样的原因产生危险,这在之后的内容中会提到。

(5). 令人头疼的多重指针

  指针指针,说到底其实还是一个变量,既然是变量那就存在于内存中,指针也会有自己的地址,所以如果我们对指针试着取个地址呢:

#include <stdio.h>
int main()
{int a = 0, *p1 = &a;printf("a = %d\n",a);printf("&a = %p\n", p1);printf("&p = %p\n", &p1);return 0;
}

  还真能行!我们对一个指针取了地址并打印出来,我们把&p1用p2存起来,那应该写成这样:

int** p2 = &p1;

  这个p2就叫做二重指针,即指向指针的指针,不过如同我之前说的多维数组一样,C语言中也没有内置二重指针这种类型,所以二重指针三重指针这种也只是:指针的指针指针的指针的指针
  多重指针的理解是这样的:有一个变量Zeta,Alice说去找Bob,Bob说去找Caren,就这么一直传下去传到了Y,Y说去找Zeta,我们终于找到Zeta了,也就是说,Y写着Zeta地址的纸,再往前一直如此,最终Alice是写着Bob地址的纸。
  他的确是可以这么一直取地址下去,不过指针的维度越高,对于人来说就越难以理解,所以一般来说用的更多的也只有一重指针和二重指针

(6). 数组和指针的关系

#1.指针的加减运算

  我们再把之前指针的值拿过来:(0x代表这是一个十六进制数)
p=0x0000008c375ff86cp = 0x0000008c375ff86c p=0x0000008c375ff86c
  这个p是int* 类型的,不过我们也并没有说p是常量,那地址肯定是可以改变的,假设我们对p做一次自增会怎么样呢?

#include <stdio.h>
int main()
{int a = 12, *p = &a;printf("p = %p\n", p);printf("++p = %p\n", ++p);return 0;
}

  好像这个地址的变化不像我们想的一样只是简单的加一,这个++p和p相差的值貌似是4,而且好像sizeof(int)也是4啊,我们再来多试几次:

#include <stdio.h>
int main()
{int a = 12, *p = &a;printf("sizeof(int) = %u\n", sizeof(int));for (int i = 0; i < 5; i++) {printf("p = %p\n", p++);}double b = 12.0, *p1 = &b;printf("sizeof(double) = %u\n", sizeof(double));for (int i = 0; i < 5; i++) {printf("p1 = %p\n", p1++);} return 0;
}

  这回我还顺便实验了一下double,果然每次+1之后地址都是直接加了一个sizeof(type),这是为什么呢?很简单:类型是固定的,每一次向后跳转都是逻辑的跳转,一个int类型占用四个字节,假设我们只往后偏一个字节,相当于是访问了一个int值四个字节中的第三个字节往后的内容,这就乱了套了是吧,所以对于指针的加减运算,都是直接跳转指针的类型对应的字节数
  每次+1都可以向后跳转一个int值,我们可以看到上面的p,每一个之间都差了4,这说明这几个地址在内存中是连续的,好像数组就是一种在内存中分配一片连续内存用于存储数据的东西来着,是吧?那数组是不是和指针有着什么联系呢?

#2.连续的内存

  所以的确,数组与指针有着千丝万缕的联系,我们先来做这么一件事情:

#include <stdio.h>
int main()
{int a[5] = {0};for (int i = 0; i < 5; i++) {printf("%p\n", &a[i]);}return 0;
}

  果然!数组a中的五个数字的地址都是连着的!这就验证了我说的:数组是分配一片连续内存来存储数据的结构。在C语言中,数组和指针是可以互相转换的,比如我们如果直接用%p打印出a和a[0]的地址:

#include <stdio.h>
int main()
{int a[5] = {0};printf("a = %p\n", a);printf("&a[0] = %p\n", &a[0]);return 0;
}

  没错,以地址形式访问数组a时,访问的就是数组的第一个元素,所以有这个等式:&a[0] == a,指针与数组就可以互相转换了。
  不过这样的转换过程是退化的过程,因为数组是分配一片内存的,它是确定大小的指针只能保证指向的位置有效,后续的内存是否属于这个数组是不明确的,比如我们看看下面这个例子:

#include <stdio.h>
#include <stdlib.h>
int main()
{int a[5] = {0};int* b = (int*)malloc(5*sizeof(int));printf("sizeof(b) == %d\n", sizeof(b));printf("sizeof(a) = %d\n",sizeof(a));return 0;
}

  在这里我使用了后面一节会讲到的malloc函数,你先别管那么多,b就是一个能容纳5个int值的数组,我们把a和b的字节数都用sizeof输出出来,结果发现,明明都是5个int值,为什么a是正确的,b是错误的呢?
  其实是这样的:b虽然是一个数组,但是它其实是一个动态分配出来的指针,它不具备数组的特性,只是我们可以通过数组的方式进行访问而已,因为我使用的机器是64位,对于内存的寻址应该是从0000,...,0000→1111,...,11110000,...,0000 \to 1111,...,11110000,...,0000→1111,...,1111(64位),所以内存占用8字节存储地址,而a是数组,有确定的大小,所以打印出来就是5*sizeof(int) = 20。
  因此你明白了,数组和指针虽然有联系,但他们不完全是相同的数组可以转换为指针,不过转换的过程属于退化,指针只对指向的元素负责,后续的元素并不能保证是被分配给当前指针的。

#3.传入数组的函数

  把数组作为参数传入函数是一个很自然的想法,在此我就不再多言,对于一维数组我们可以:

int f(int arr[]);

  对于一维数组,传数组可以不用确定数组大小(中括号内不用填数字),当然填了也行,只是这样一来就会限定传入数组的大小。 不过对于二维数组来说,这件事情是不成立的:

int f(int arr[][size]);

  传入二维数组时,后面一个维度的参数必须传入,否则就不能通过编译,不过你可能想这么一件事情:我们如果用C语言写一个跟矩阵有关的函数,那矩阵规模不能直接确定,后续通过n,m两个参数传入即可,能不能这么做呢:

int f(int arr[n][m], int n, int m);

  这样不行,因为n和m还没有声明过,不过改成下面这样就可以了:

#include <stdio.h>
int f(int n, int m, int arr[n][m]);
int main()
{int n = 3, m = 4;int mat[3][4] = {0};for (int i = 0; i < 3; i++) {for (int j = 0; j < 4; j++) {mat[i][j] = i + j;} }for (int i = 0; i < 3; i++) {for (int j = 0; j < 4; j++) {printf("%d ",mat[i][j]);} printf("\n");}printf("%d\n",f(n, m, mat));return 0;
}
int f(int n, int m, int arr[n][m])
{return arr[n-1][m-1];
}

  还真行,这回我甚至都不知道可以这么做了哈哈哈哈哈哈,这个例子当中比较重要的一点是:在具体调用函数传数组的时候,数组的后面不要跟中括号,否则它代表的是取值而不是传入数组!这么写参数表我不知道算不算是特性,一般来说如果要传入规模不定的多维数组更多的应该是采用退化成指针的方式

int f(int** arr, int n, int m);

#4.返回数组的函数

  假设有这么一个情景:你把一个数组arr传入了函数f,在函数中对arr进行了一些操作,然后再操作完成后,你希望函数把数组返回,这样就可以在别的地方把这个数组赋值给一个新的变量了,你可能想这么写:

#include <stdio.h>
int[] f(int arr[], int n)
{for (int i = 0; i < n; i++) {arr[i] *= 2;}return arr;
}
int main()
{int n = 10, arr[10] = {0};for (int i = 0; i < 10; i++) {arr[i] = i + 1;}int arr2[10] = f(arr, 10);for (int i = 0; i < 10; i++) {printf("%d ", arr2[i]);}return 0;
}

  这段代码都不用跑,你就知道肯定不能正常运行,我在多维数组的那一部分就曾经提到过,C语言中没有int[]这样的数据类型,所以通过这样的方式返回一个数组是做不到的,不过嘛…C语言也没有完全堵死返回数组的操作:我们可以把数组退化成指针再返回

#include <stdio.h>
int* f(int arr[], int n)
{for (int i = 0; i < n; i++) {arr[i] *= 2;}return arr;
}
int main()
{int n = 10, arr[10] = {0};for (int i = 0; i < 10; i++) {arr[i] = i + 1;}int* arr2 = f(arr, 10);for (int i = 0; i < 10; i++) {printf("%d ", arr2[i]);}printf("\n");return 0;
}

  大功告成!我们用f函数返回了一个处理过后的数组。由此你就明白了:由于不存在int []类型,我们就必须把数组退化成为指针再作为返回值返回。
  还有一个问题:我们说C语言的函数参数都是传值的,那传入的这个数组arr和main函数中的arr是同一个对象吗?

#include <stdio.h>
int* f(int arr[], int n)
{printf("In f, arr = %p\n", arr);for (int i = 0; i < n; i++) {arr[i] *= 2;}return arr;
}
int main()
{int n = 10, arr[10] = {0};for (int i = 0; i < 10; i++) {arr[i] = i + 1;}printf("In main, arr = %p\n", arr);int* arr2 = f(arr, 10);for (int i = 0; i < 10; i++) {printf("%d ", arr2[i]);}printf("\n");return 0;
}

  有那么一点出乎意料,不过也在情理之中,事实上对于数组参数,对于它的传值是传入地址,而不是将数组复制一份

#5.一些运算符的优先级

  你时常可以从别人的代码中看到这样的东西:*p++,p是一个指针,p++就是自增操作,不过返回本身的值,而*p则是访问对应地址的值,所以总结一下就是,访问当前地址的值,然后把地址向后推一位, 这是个很聪明的操作,至少代码可以少写那么一两行,假设你有一天也这么写,不过多写了一个括号,即(*p)++,会怎么样呢?

#include <stdio.h>
int main()
{int a = 10, *p = &a;printf("p = %p\n", p);printf("a = %d\n", (*p)++);printf("p = %p\n", p);printf("a = %d\n", a);return 0;
}

  结果就是指针p没变,a的值还加了1,这是因为这个括号改变了运算的优先级,我们应该来了解一下 *, &, ++, --等等运算符之间的优先级

  •   !, ~, ++, --, -, *, &, sizeof()的优先级其实是相同的,在他们同时出现的时候,遵循从右向左结合的规则。
  •   (), [],.和->的优先级要高于上述运算符,(.和->我们之后来介绍)

#6.以指针的方式访问数组

  结合前面几节的内容,就很好想到了:

int arr[3] = {0};
int i = 1;
printf("%d\n", *(arr + i));

  这很好,因为arr就是首元素的地址,加上一个偏移量i之后,就是第i个元素的地址了,再通过*访问,就完成了!

#7.为什么数组下标要从0开始?

  看我上一节加粗的偏移量i,没错,数组下标从0开始就是因为这个偏移量(offset),我们以指针方式访问数组的时候,*arr就相当于*(arr+0),相对于arr这个地址偏移的量为0,所以首元素就是从下标0开始了。
  从0开始当然不是计算机的什么神秘仪式,下标从0开始就是从偏移量开始的,后来很多的编程语言已经没有指针了,但是他们仍然保留了从0开始这个规则。

#8.数组指针与指针数组

  数组指针比较好说,&a[0]或者直接用a,他们就是一个数组指针,即指向数组的指针指针数组则是一个存储指针的数组,我们来看个例子:

#include <stdio.h>
int main()
{int* array[3] = {NULL};int a = 1, b = 2, c = 3;array[0] = &a;array[1] = &b;array[2] = &c;for (int j = 0; j < 3; j++) {printf("%p : %d\n", array[j], *array[j]);}return 0;
}

  这么以来我们就存了三个指针进入数组中,这就是指针数组。

(7). 动态内存分配

  可算是讲到这里了,我在数组篇就挖的坑到这儿该填了,这次我们来讲讲stdlib.h头文件中的malloc函数。
  之前提到:直接声明并定义的数组是存放在栈内存中的,不过栈内存的空间并不算很大,而且如果不用VLA的话,我们不能在运行期确定数组的大小。那C语言当然能解决这个问题啊,堆内存那么大,不用岂不是很浪费? calloc、malloc和realloc函数就是用于处理这个问题的好方法。

#1.从malloc说起:

#include <stdio.h>
#include <stdlib.h>
int main()
{int n = 0;scanf("%d", &n);int* a = (int*)malloc(n * sizeof(int)); // malloc的使用方法for (int i = 0; i < n; i++) {scanf("%d", &a[i]);}for (int i = 0; i < n; i++) {printf("%d ", a[i]);}free(a); // 记得freereturn 0;
}

  还不错,这段代码里我们用malloc分配了一片内存给a,之后就可以按照数组的方式操作了!这就是动态分配内存的魅力,数组的大小n也是由用户输入确定的,这也满足了我们在数组篇就希望的在运行期确定数组大小。
  malloc函数的原型如下:

void* malloc(size_t size);

  其中的参数size就是分配内存的字节数,在我们使用的时候,由于机器和编译环境不同可能不确定int的字节数,所以我们可以用n*sizeof(int)的方式自动计算出字节数,然后分配内存,当然,n*sizeof(double)等等也都是可以的,然后由于函数分配后得到的是void型指针,我们得把它显式类型转换为int*才行。
  在最后,我们还调用了free()函数,这个函数没有返回值。只要使用了动态内存分配,那么我们就一定需要调用free()把这片内存还给操作系统:虽然程序退出的时候的确会自己把空间还回去,但是有的时候我们可能需要长期运行一个程序,假设经常会有malloc分配内存,如果一直不free掉,迟早有一天所有的内存都会被消耗殆尽,那就很有可能就会导致软件崩溃,甚至系统崩溃。所以说,一定要记得在适当的地方free!

#2.改进后的calloc

  calloc函数与malloc其实差不多,不过是malloc的改进版,它的原型如下:

void* calloc(size_t num, size_t size);

  其中num是元素的个数,size是每个元素所占据的字节数,这个函数与malloc的最大区别是:调用calloc分配的内存会自动在每个位置上赋0值,有的时候这好像是个不错的操作呢。

#3.灵活伸缩的realloc

  realloc是re-allocate的意思,利用realloc函数可以重新分配已经分配好的内存,它的原型如下:

void* realloc (void* ptr, size_t size);

  ptr就是已经分配好的内存对应的指针,size是新的空间大小,变大变小都是可以的。在此就不过多介绍了。

#4.多维数组和malloc函数

  说了malloc函数可以用于动态分配一个一维数组出来,那二维数组甚至是多维数组呢?很容易想到我们可以这么做:

#include <stdio.h>
#include <stdlib.h>
int main()
{int n = 0, m = 0;scanf("%d%d", &n, &m);int** arr = (int**)malloc(n * sizeof(int*));for (int i = 0; i < n; i++) {arr[i] = (int*)malloc(m * sizeof(int));}for (int i = 0; i < n; i++) {for (int j = 0; j < m; j++) {arr[i][j] = i + j;}}for (int i = 0; i < n; i++) {for (int j = 0; j < m; j++) {printf("%d ", arr[i][j]);}printf("\n");}for (int i = 0; i < n; i++) {free(arr[i]);}free(arr);return 0;
}

  我们创建了一个三行五列的矩阵啊,首先是新建了一个int**类型的变量arr,之后用malloc分配n个sizeof(int*)的空间用于存储n个能容纳m个int元素的内存的地址,然后从arr[0]到arr[n-1]一个一个去用malloc分配内存,这样就形成了一个二维数组了!
  程序结束之前,我们先用for循环把arr中每个元素地址对应分配的内存给free掉再把arr对应的内存也给free掉,这样就好了,对于更高维度的数组我们也可以做类似的操作。
  这个过程中,arr其实就是一个指针数组,它的每一个元素都是一个指针。
  不过有一个问题:为什么我们一定要用for循环一个一个元素去free呢?我只free掉arr不行吗?要回答这个问题,我们把上面的程序中的printf("%d ", arr[i][j]);改为打印每个元素的地址试试:

  哦吼?我们会发现第一行在最后一个元素和第二行第一个元素之间并不是只差4字节三个int*之间都不是连续的,与之对比,我们来看看int a[3][5]的表现:

  分配在栈内存上的多维数组中的元素地址是连续的,所以这也就告诉我们:动态内存分配得到的多维数组与栈内存上的数组是不同的,动态内存分配得到的数组的每一行之间可能是不连续的,因此我们不能通过一个free一次把他们全都归还,所以必须要用for循环的方式一行一行free掉

#5.健忘的人们和迷茫的人们

  初学者们经常把free忘掉,毕竟大家之前用数组也没有说要free掉,这是个习惯问题,而动态内存分配的老手们则会迷茫:我应该在什么时候调用free呢?很多人总是不知道应该在什么时候使用free来节省内存,当然,我有的时候也会遇到这些问题,这可能需要你长期的coding经验,所以,多尝试一下,总是没有坏处的。
  不得不再提一下C++,之前我有提到C++中的引用,这是“去指针化”的步骤之一,当然因为要兼容C语言不可能把指针全部去掉,那有没有什么好办法可以避免忘记归还内存呢?

  • 先说一句:C++中可以使用new语句来动态分配内存:
int* a = new int[n];

  这要比C语言的malloc先进不少,至少我不用自己去算字节数了是吧。对应的,如果要归还内存,需要用到delete语句:

delete[] a;
或
delete a;

  中间加不加中括号取决于你的a是一个数组还是仅仅是一个对象指针。在C++中,大家也会经常忘记delete这件事情,所以从C++03开始,出现一个新东西:智能指针,早期的是auto_ptr,在C++11标准中auto_ptr被移除,取而代之的是unique_ptr和shared_ptr,他们的具体区别我就不提了,但是他们最大的特点是:在指针对应的对象生命期结束的时候会自动调用对象的析构函数,然后自动delete掉这个它,这可真是个不错的事情

(8). 等会儿,void* 是个啥?

  上一节在讲malloc函数的时候我们说:malloc返回的是一个void类型的指针,我一下就跳过了,不过我想你应该会感到奇怪吧,我们说void是指什么都没有的空类型,那void*是啥?
  void* 并不是与void对应的“空指针”,它真正的意思是无类型指针,即我们通过malloc分配的仅仅只是一片连续内存,没有对空间进行划分,这样一来也就没有类型的区别了。
  那void*除了被我们转换为不同类型的指针,它就没有别的作用了吗?你先别急,我们来看下面这一段代码:

#include <stdio.h>
#include <stdlib.h>
int main()
{void* array[3];int a = 1;double b = 0.123;char* c = "Hello";array[0] = (void*)&a;array[1] = (void*)&b;array[2] = (void*)c;printf("1.%d\n",*(int*)array[0]);printf("2.%f\n",*(double*)array[1]);printf("3.%s\n",(char*)array[2]);return 0;
}

  先缓缓,我们先来解释一下这段代码在干什么:首先创建一个容纳三个无类型指针的数组,然后把整数a,双精度浮点数b和字符串c转换成无类型指针再存入array中,之后我们把各个元素取出来再解引用,打印出了不同的值,由此一来,我们便在一个数组中存放了三个不同类型的元素,是不是感觉让你大受震撼?在类型系统这么强的C语言中,居然能够有一个数组同时存放三个类型的元素!
  除此之外,void*还有一个用途,在stdlib.h头文件中有一个内置函数叫做qsort,它的原型如下:

void qsort(void* base, size_t num, size_t width, int(__cdecl* compare)(const void*,const void*));

  有点复杂,不过其实四个参数都很好理解:

  • void* base:被排序的数组(不指定数组类型)
  • size_t num:被排序数组中元素的个数
  • size_t width:一个元素的字节数
  • int(__cdecl* compare)(const void*,const void*):这个有点复杂,其中的compare是一个函数指针,返回值的类型为int,它的两个参数均为const void* 类型,即无类型指针类型,使用const保证了compare内部不会对传入值进行修改

  那么数组base使用的void*确保了无论传入什么样的数组都是可以排序的,这种在强类型语言中可以应对各种不同类型的函数就是泛型,泛型可以减少程序员的劳动,不必再对每一种类型都单独写一个函数了,不过在C语言中,通过void*实现的泛型还是很受限制的,比如你可能可以想到这么一件事情:

void swap(void* arg1, void* arg2)
{void temp = *arg1;*arg1 = *arg2;*arg2 = temp;
}

  看似很有道理,这样一来这个swap函数不管传入什么都可以交换了,不过实际上如果你仔细看看就会发现问题:

    void temp = *arg1;

  在C语言中,变量的类型不能为void,这样一来temp就不能产生了,而不止于此,后面的*arg1也是错误的,我们在解引用操作的时候会返回指针对应地址的值,这个值是与类型相关的:int型占4字节,double型占8字节,如果连类型都不能确定,C语言从这个地址向后取几个字节作为返回值呢?当然,针对于不同类型的swap函数我们之后会实现,但不是用如上的办法。
  这个例子就说明了一个事情:C语言并没有完全支持泛型这种编程范式,更多的只是通过void*取了个巧,C++支持非常非常多的编程范式,泛型就是其一,泛型在C++中的体现是模板,当我们声明模板,利用模板编写函数或类之后,在调用的过程中编译器会自动生成对应类型的代码

#include <iostream>
template<typename T>
void swap(T& arg1, T& arg2)
{T temp = arg1;arg1 = arg2;arg2 = temp;
}
int main()
{int a = 1, b = 2;swap<int>(a, b);std::cout << "a = " << a << "," << "b = " << b << std::endl;return 0;
}

  以上的代码你可以自己尝试执行一下,在此我就不过多阐述了。void*指针的更多妙用你可以自己尝试一下,在这里我想再说一说qsort函数。

  • qsort函数是C语言内置的快速排序函数,可以对传入的任何类型数组实现快速排序,而使用qsort最关键的一点是要自己写一个compare函数,compare函数的原型是这样:
int compare(const void* vp1,const void* vp2);

  返回值类型必须为int,传入的两个参数类型均为void*类型,举个例子:

int compare(const void* vp1, const void* vp2)
{return *(int*)vp1 - *(int*)vp2;
}

  这就是一个针对于int类型值的排序,当compare的返回值大于0时,vp1对应的元素会排在vp2的右边;小于0时,vp1则会在vp2的左边,等于0时,二者的顺序不确定。
  排序的规则由你自行定义,不过要保证传入的指针类型以及传出的值类型

(9). 字符串去哪儿了?

#1.我们见到的字符串

  我还是觉得你应该是学过或者至少了解过python之类的其他语言,在这些语言中你可能很早就接触过了 “字符串” 这个概念,很简单,就是一串字符构成的一种数据类型,有的时候我们输出运算结果,也可以增加一些辅助文字——即构造一个字符串用于打印,那C语言的字符串去哪了?我们先来看看这个:

#include <stdio.h>
int main()
{int b = 23;printf("The value of b is %d\n", b);return 0;
}

  我就不加运行截图了,你很容易知道会打印出什么东西来。其实,在printf函数中用一对双引号所括起来的部分就是一个字符串,我们一直都在用它,只是没有提出来而已,不过这样的字符串被称为字符串字面量,是一个常量。字符串字面量存储在内存中的常量区,看下面这个例子:

#include <stdio.h>
#include <stdlib.h>
int main()
{printf("p1 = %p\n", &"Hello,world!");int a = 0;int*b = (int*)malloc(8);printf("p1 = %p\n", &"Hello,world!");free(b); // 不要忘记free()哦return 0;
}

  中间写一段的目的是排除在栈内存和堆内存中的可能性,即便我们没有把"Hello,world!"这个字符串赋给某个变量,它还是被储存下来了,这也就说明了字符串字面量是存储在常量区的。

#2.自定义的字符串

  遗憾的是C语言没有提供一个类似于C++/Java中的string类,C语言的字符串是基于字符数组实现的,其与字符数组最大的区别是:字符串的尾部需要添加’\0’字符作为结尾标志,我们可以把’\0’字符称为空字符,它的ASCII码值正好是0,所以在给某一位赋值为’\0’时也可以直接赋值为0。
  字符串"Hello,world!"是这样存储(数组形式)的:

  在C语言中初始化这样一个字符串有三种方法:

#include <stdio.h>
int main()
{char str1[15] = {'H','e','l','l','o',',','w','o','r','l','d','!','\0'}; // 方法1char str2[15] = "Hello,world!"; // 方法2char* str3 = "Hello,world!"; // 方法3printf("%s\n", str1);printf("%s\n", str2);printf("%s\n", str3);return 0;
}

  我们用%s作为字符串的格式化占位符,可以将字符串打印出来,三种方法中,前两种是在字符数组的基础上操作的,只要保证数组长度大于等于字符总数就行了,第三种则是以指针的形式出现的。当然,第一种方式有种不太聪明的感觉…
  第一种就不必说了,第二种是把字符串拷贝一份过来形成的新字符串,而第三种则是直接指向字符串常量

#include <stdio.h>
int main()
{char str1[15] = {'H','e','l','l','o',',','w','o','r','l','d','!','\0'}; // 方法1char str2[15] = "Hello,world!"; // 方法2char* str3 = "Hello,world!"; // 方法3printf("str1p = %p\n", str1);printf("str2p = %p\n", str2);printf("str3p = %p\n", str3);printf("strSp = %p\n", &"Hello,world!");return 0;
}

  strSp是常量区的"Hello,world!"的地址,str3的地址和它是完全一致的。

(10). 字符串/数组的更多操作

  在C语言中有一个叫做string.h的头文件,其中包含了很多字符串相关的函数,并且还有一部分内存操作的函数,我们将介绍:strlen(),strcpy(),strcat(),strcmp(),memcpy(),memset()这几个函数。

#1.strlen()

  • 函数原型:size_t strlen(const char* str);
  • 参数:字符串str
  • 作用:数出字符串str的字符数(不包含’\0’)
  • 返回值:字符串str的长度(不包含字符串尾部的’\0’字符)
  • 例子:
#include <stdio.h>
#include <string.h>
int main()
{char* str = "Hello,world!";printf("strlen(%s) = %d\n", str, strlen(str));return 0;
}

  • 注意:请保证传入的是字符串(包含’\0’且在正确位置上)而不是字符数组

#2.strcpy()

  • 函数原型:char* strcpy(char* dest, const char* src);
  • 参数:目标字符串dest,源字符串src
  • 作用:将源字符串src的内容复制到目标字符串dest
  • 返回值:指向目标字符串dest的指针
  • 例子:
#include <stdio.h>
#include <string.h>
int main()
{char dest[100] = {0};char* src = "Wonderful day!";char* d = strcpy(dest, src);printf("d = %s\n", d);printf("dest = %s\n", dest);return 0;
}

  • 注意:目标数组dest的长度要足够,否则如果源字符串太长可能会出现溢出的问题

#3.strcat()

  • 函数原型:char* strcat(char* dest, const char* src);
  • 参数:目标字符串dest,源字符串src
  • 作用:将源字符串src的内容拼接到目标字符串dest之后
  • 返回值:指向目标字符串dest的指针
  • 例子:
#include <stdio.h>
#include <string.h>
int main()
{char dest[100] = "Hello,";char* src = "world!";char* d = strcat(dest, src);printf("d = %s\n", d);printf("dest = %s\n", dest);return 0;
}

  • 注意:目标数组dest的长度要足够,否则如果源字符串太长可能会出现溢出的问题

#4.strcmp()

  • 函数原型:int strcmp(const char* str1, const char* str2);
  • 参数:需要比较的字符串str1和str2
  • 作用:两个字符串自左向右逐个字符相比(按 ASCII 值大小相比较),直到出现不同的字符或遇 \0 为止
  • 返回值:return < 0 →\rightarrow→ str1 < str2;
    return == 0 →\rightarrow→ str1 == str2;
    return > 0 →\rightarrow→ str1 > str2;
  • 例子:
#include <stdio.h>
#include <string.h>
int main()
{char* str1 = "Hello";char* str2 = "ABC";printf("strcmp(%s, %s) = %d\n", str1, str2, strcmp(str1, str2));return 0;
}

  • 注意:返回的具体值与编译器有关,C语言标准仅规定了返回值的正负

#5.memcpy()

  • memcpy和memset不仅仅是字符串的操作,他们本身都是直接对内存操作,因此无论是什么类型的指针都可以
  • 函数原型:void* memcpy(void* vp1, const void* vp2, size_t n);
  • 参数:数据源指针vp2,被复制入的指针vp1,复制的字节数n
  • 作用:从vp2的地址起向后取n个字节的数据,复制进入vp1中
  • 返回值:指向被复制入的指针vp1
  • 例子:
#include <stdio.h>
#include <string.h>
int main()
{char str1[50] = "Hello, WORLD";char* str2 = "world";char* d = memcpy(str1+6, str2, 6*sizeof(char));printf("str1 = %s\n", str1);printf("d = %s\n", d);return 0;
}

  不要感到奇怪,我们传入的vp1为str1+6,即’W’字符的地址,调用类似的以无类型指针为参数的函数可以不需要进行显式类型转换,这里的作用就是把str1中的WORLD替换成了world,你也可以自己尝试一下。

  这里要提一下我之前所说的 "泛型版"swap函数,对于不存在void类型变量这件事情,我们可以用memcpy来替代:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
void swap(void *vp1, void *vp2, int size)
{  void* buffer = malloc(size);memcpy(buffer,vp1,size);  memcpy(vp1,vp2,size);  memcpy(vp2,buffer,size);free(buffer);
}
int main()
{int a = 12, b = 23;swap(&a, &b, sizeof(int));printf("a = %d, b = %d\n", a, b);return 0;
}

  没错,这样就实现了一个真正的泛型版swap函数,只要我们每次都在调用的时候传入对应的字节数就可以了。

#6.memset()

  • 函数原型:void* memset(void* vp, int c, size_t n);
  • 参数:被赋值的指针vp,需要赋的值c,字节数n
  • 作用:将从vp的地址起的后面n个字节均赋值为c
  • 返回值:被赋值的指针vp
  • 例子:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int main()
{int* array = (int*)malloc(10*sizeof(int));printf("Before memset: ");for (int i = 0; i < 10; i++) {printf("%d ", array[i]);}printf("\n");memset(array, 0, 10*sizeof(int));printf("After memset: ");for (int i = 0; i < 10; i++) {printf("%d ", array[i]);}printf("\n");free(array);return 0;
}

  你看,只要一条memset()我们就可以把数组的每一个元素初始化为0,当然初始化为其他值也是可以的。memset()也可以对字符串赋值,这个就留给你自己去尝试了。

  • 其实string.h还有更多函数,如果你想了解他们,可以参考一下菜鸟教程:<string.h>

(11). 现在我们就可以说说int argc和char* argv[]是什么东西了

  我们之前提过老朋友main()函数中实际上有两个参数,它的原型如下:

int main(int argc, char* argv[]);

  main函数是C语言的入口函数,char* argv[]是一个字符串数组,int argc是这个数组中包含的元素数量,假设我们在调用的时候给它加点参数,可能会有神奇的效果:

#include <stdio.h>
int main(int argc, char* argv[])
{for (int i = 0; i < argc; i++) {printf("%d : %s\n", i, argv[i]);}return 0;
}

  这一次的编译运行我们放到Linux下完成:

  当我们直接执行这个程序的时候,它打印的结果是0 : ./argv.out,也就是说argv[0]是执行程序的命令,那如果我们在运行的时候多加一点东西会怎么样呢?

  没错,我们可以执行的同时向C语言程序附加很多不同的指令/参数,这些东西也会被加入argv数组中,作为参数传进main函数,这样一来我们就可以做很多事情了,比如设置一个debug模式:只需要在启动程序的时候后面加一条Debug参数就可以了,还有很多诸如此类的用法。

(12). 自由的指针和不自由的程序员

  你可以在你的程序中任意指定某个指针指向任意一个地址,当然能不能成功就看你的运气了,因为有的时候可能你输入的地址超出范围、进入了不该进入的地方等等,这些时候程序会直接崩溃,然后可能return一个相当大的值告诉你运行异常。

#include <stdio.h>
int main()
{int* p = 0x1;printf("%d\n", *p);return 0;
}

  你也可以试试,这说明我们没有办法读写0x1(0x表示16进制数字)这个位置上的值,从0x0起的一部分内存区域用于存储操作系统等等的一些重要信息,对于程序来说,这一部分内容是不可读写的,否则可能导致操作系统的崩溃。
  当然,指针还是自由的,因为你仍然可以给p赋值为0x1,只是在读写的时候会出问题罢了,指针给C语言程序员带来了非常非常多的方便之处,我之前说的swap函数就是其中之一,不过与此同时,指针也带来了很多安全隐患,除了前面所说的尝试修改不可读写的部分,还有:

  • 越界访问:数组的越界访问、部分不对空间进行检查或者限制的函数如strcpy和strcat等
  • 内存泄露:使用malloc或calloc分配的内存在结束前没有释放

  所以虽然指针很自由,但是我们这些程序是不自由的,在用指针的时候一定要处处当心,毕竟你也不想写个小程序结果把电脑给玩崩了是吧?之后用指针的时候,一定要小心谨慎

(13). 关于“地址”的思考——我们能不能做个游戏修改器(CE)?

  到这里,你肯定已经对指针有了深入的理解,就算不深入,也肯定收获了很多,我当初学指针的时候,就对 “地址” 这个事情产生了非常强烈的兴趣,为什么这么说呢?之前玩很多单机游戏的时候,有一个叫做Cheat Engine(后称CE)的游戏修改器,它的具体用法是这样的:

  • 首先确定游戏中的某个数值,例如我要修改植物大战僵尸一局游戏中的阳光数,一开始初始值是25,我先在CE中搜索25,它会出现一大堆候选位置。
  • 搜索完之后,在游戏中再次修改阳光数量,比如向日葵产生了25阳光收集一下,现在就是50了,然后再在CE搜索一下50,这时候候选区的数量就会明显减少,之后再重复改变和搜索的过程
  • 直到只有一个候选或只有两个候选,这时候对这个地址直接进行修改就行了,然后你就会发现阳光数变成了你想要的数字

  我在这段描述中提到了关键词:“地址”,所以CE其实是对特定的地址写入值,从而达到修改游戏内变量的效果,我们是不是刚才学过指针和地址来着,那我们来试试看吧!

// 这是第一个程序,是需要被修改的值
#include <stdio.h>
int main()
{int a = 10;printf("a = %d\n", a);printf("&a = %p\n", &a);system("pause"); // 确保在修改过程中程序是暂停的printf("a = %d\n", a);return 0;
}// 这是第二个程序,用来修改指定地址的值
#include <stdio.h>
int main()
{int* p;scanf("%p", &p);int temp = 0;scanf("%d", &temp);*p = temp;return 0;
}

  这次我把两个窗口一起截进来了,第一个窗口是修改值用的,第二个则是被修改用的,结果是有点令人失望的,我们把地址输入第一个窗口再输入一个值,它就返回了3221225477,再看原来的窗口,a的值也没有发生改变,这说明我们的尝试失败了!
如果你真的有想到这一层并且也付诸了实践,那我相信你肯定对于计算机具有相当的兴趣,能够有这样的探索精神对于学习计算机时非常有利的!
  不过,到底为什么会这样呢?这就要回到 “地址”这个词本身了,在一个程序中使用&取地址,得到的这个地址并不是直接对应到物理内存上的,以Windows进程来说,每个进程都可以分配到自己的一个线性地址空间(0x0000…000 ~ 0xFFFF…FFFF),但这个地址没有反映其在物理内存上的真实地址,因此这么一来,我们在不同的进程中甚至可能得到相同的地址,毕竟这个地址并不对应真实地址,由此,我们仅仅通过进程A输出地址,再在进程B中修改地址的值是不可能这么简单的完成的
  这件事当然也是可以完成的,在Windows中我们可以通过Win32API的内置函数来获取真实地址然后进行修改,当然更深入的内容我也不太懂了哈哈。
  具体也可以看看这一篇专业回答:C语言中&取地址取到的地址是真实内存的地址吗?

  虽然这个试验失败了,但是我们从中可以说是学到了不少东西呢!

(14). 关于函数指针

  鉴于这是一个基础教程,我不会在这里讲函数指针的相关内容,后续可能会有单独的文章来补充这一节。

小结

  指针这一章可真是“鸿篇巨制”,我们从各个方面介绍了指针,从含义解释到应用,这么一大章的内容肯定是要花不少时间消化的,你可以多看几遍,然后对着代码敲敲、运行一下如果你从头看到了这里,你就已经可以算是C语言入门了,我的教程中并没有掺杂太多的题目,我希望你能够在学习了这些知识之后自己去尝试,思考一下我们学习的这些东西到底有什么用,例如这次13节中的自己写一个CE就是很好的尝试,虽然结果可能和想象有出入,但这个过程中你一定能学到很多很多的知识,这是刷题不能带给你的,计算机的学习也是这样,作为一门应用型的工科,你更应该多在实践中完成学习,毕竟运行结果不会骗你,该是怎么样,跑跑就知道了。
  教程写到这里就已经过了大半了,之后的几章会分别介绍优化用户交互的格式化输入与输出把数据打包的结构体和联合把数据长期保存的文件以及奇妙的位运算,真心希望这篇教程能够帮助到你!

一个C语言的基本教程—指针篇相关推荐

  1. 一个C语言的基本教程—IO篇

    文章目录 10.与用户交互的关键--IO篇 (1).I/O是什么 (2).换个办法操作字符 #1.新的朋友--getchar和putchar #2.getchar的妙用 (3).重新认识一下--pri ...

  2. 一个C语言的基本教程—基础篇

    文章目录 1. 简介: 2. 一些基础内容 3. 来看看一段代码 4. 我们来解析一下吧 5. 数据是最重要的 (1).变量与常量 (2).C的历史与变量类型的声明 (3).数据的存储方式: (4). ...

  3. 一个C语言的基本教程—位运算篇

    文章目录 13.从底层操纵数据--位运算篇 (1). 各种数据的存储方式 #1.无符号整型 #2.有符号整型 #3.字符型 #4.浮点型 (2). 什么是位运算 (3). 移位运算 (4). 位与.位 ...

  4. C语言学习笔记(指针篇)

    1.1指针是什么 关于地址: 在程序中定义一个变量系统就会分配内存单元,根据变量类型去分配一定空间的长度.每一个字节都有一个编号,这就是"地址". 通过地址能找到变量单元,所以我们 ...

  5. 牛客网C语言刷题(指针篇)

    ✅作者简介:大家好我是:嵌入式基地,是一名嵌入式工程师,希望一起努力,一起进步!

  6. 《零基础看得懂的C语言入门教程 》——(三)轻轻松松理解第一个C语言程序

    一.学习目标 了解C语言代码的一般结构 了解函数的概念 了解printf函数的使用方法 了解头文件的概念 了解system函数的使用方法 目录 C语言真的很难吗?那是你没看这张图,化整为零轻松学习C语 ...

  7. c语言教程指针,(转)C语言指针5分钟教程

    指针.引用和取值 什么是指针?什么是内存地址?什么叫做指针的取值?指针是一个存储计算机内存地址的变量.在这份教程里"引用"表示计算机内存地址.从指针指向的内存读取数据称作指针的取值 ...

  8. C语言重点——指针篇(一篇让你完全搞懂指针)

    C语言重点--指针篇(一篇让你完全搞懂指针) 一. 前言 C语言是比较偏底层的语言,为什么他比较偏底层,就是因为他的很多操作都是直接针对内存操作的. 这篇我们就来讲解C语言的一大特点,也是难点,指针和 ...

  9. c语言数组数据用指针查找,c语言数组与指针_指针篇_2011.ppt

    c语言数组与指针_指针篇_2011 指 针 6.2 指针的概念6.3 指针与数组6.4 字符串的指针6.5 指针数组和指向指针的指针;6.2.1 地址与指针的概念 ;指针的概念;内存地址;2.数组与地 ...

最新文章

  1. angularjs 1.3 综合学习 (one way bind , ng-if , ng-switch , ng-messages, ng-form ,ng-model )
  2. RestTemplate设置通用header
  3. java多个数据库数据进行访问_通过Spring Boot配置动态数据源访问多个数据库的实现代码...
  4. js笔记(三)ES5、ES5新增的数组的方法、字符串的方法、字符编码、对象的序列化和反序列化、bind
  5. java内存分配空间大小,JVM内存模型及内存分配过程
  6. android adb shell常用命令(四)
  7. 如何绘制逻辑图 — 3.要素的属性:粒度与分层
  8. MQTT onenet 使用记录
  9. parquet : java.lang.NoSuchFieldError: BROTLI
  10. mysql 取消密码警告
  11. Mac上的硬盘有问题该如何修复?
  12. 【视频】R语言中的分布滞后非线性模型(DLNM)与发病率,死亡率和空气污染示例
  13. 话费充值 php,话费充值示例代码
  14. Java换行输出的5种方式
  15. 怎样利用通达信软件调出半年线和年线?
  16. IPS和IDS的区别
  17. 重构kz-admin
  18. Python-玩转数据-Scrapy中Spiders
  19. Android手游3d模型导出,楚留香手游3D模型怎么提取 提取教程
  20. Dell神州网信版 Win10 忘记登陆密码

热门文章

  1. 背包问题最大价值java,背包问题Java实现
  2. 淘宝直播系统开发技术干货:高清、低延时的实时视频直播技术解密
  3. _In_ 是什么意思
  4. 【蓝牙mesh】蓝牙Mesh的三种Model
  5. dell R730服务器介绍
  6. 十张GIFs让你弄懂递归等概念
  7. 《运维管理平台OpsManage》
  8. Word怎么删除空白页,4个方法轻松解决!
  9. js 快速实现“当月份小于10时前面加0”
  10. mvn package、mvn install和mvn deploy区别