你的浏览器版本过低,可能导致网站不能正常访问!
为了你能正常使用网站功能,请使用这些浏览器。

指针是C语言的「精华」

[复制链接]
gaosmile 发布时间:2020-11-4 18:53

说到指针,估计还是有很多小伙伴都还是云里雾里的,有点“知其然,而不知其所以然”。但是,不得不说,学了指针,C语言才能算是入门了。指针是C语言的「精华」,可以说,对对指针的掌握程度,「直接决定」了你C语言的编程能力。


在讲指针之前,我们先来了解下变量在「内存」中是如何存放的。

在程序中定义一个变量,那么在程序编译的过程中,系统会根据你定义变量的类型来分配「相应尺寸」的内存空间。那么如果要使用这个变量,只需要用变量名去访问即可。

通过变量名来访问变量,是一种「相对安全」的方式。因为只有你定义了它,你才能够访问相应的变量。这就是对内存的基本认知。但是,如果光知道这一点的话,其实你还是不知道内存是如何存放变量的,因为底层是如何工作的,你依旧不清楚。

那么如果要继续深究的话,你就需要把变量在内存中真正的样子是什么搞清楚。内存的最小索引单元是1字节,那么你其实可以把内存比作一个超级大的「字符型数组」。在上一节我们讲过,数组是有下标的,我们是通过数组名和下标来访问数组中的元素。那么内存也是一样,只不过我们给它起了个新名字:地址。每个地址可以存放「1字节」的数据,所以如果我们需要定义一个整型变量,就需要占据4个内存单元。

那么,看到这里你可能就明白了:其实在程序运行的过程中,完全不需要变量名的参与。变量名只是方便我们进行代码的编写和阅读,只有程序员和编译器知道这个东西的存在。而编译器还知道具体的变量名对应的「内存地址」,这个是我们不知道的,因此编译器就像一个桥梁。当读取某一个变量的时候,编译器就会找到变量名所对应的地址,读取对应的值。

初识指针和指针变量

那么我们现在就来切入正题,指针是个什么东西呢?

所谓指针,就是内存地址(下文简称地址)。C语言中设立了专门的「指针变量」来存储指针,和「普通变量」不一样的是,指针变量存储的是「地址」

定义指针

指针变量也有类型,实际上取决于地址指向的值的类型。那么如何定义指针变量呢:

很简单:类型名* 指针变量名

char* pa;//定义一个字符变量的指针,名称为pa
: G& r7 S5 b' {. d" Q8 u- V* n  o/ eint* pb;//定义一个整型变量的指针,名称为pb; U. w2 i  c7 P# H: d3 c
float* pc;//定义一个浮点型变量的指针,名称为pc5 e% N; ~$ N8 L" M- n1 o/ W% r

注意,指针变量一定要和指向的变量的类型一样,不然类型不同可能在内存中所占的位置不同,如果定义错了就可能导致出错。

取地址运算符和取值运算符

获取某个变量的地址,使用取地址运算符&,如:

char* pa = &a;
8 o% d& q, y' Y- y: d6 zint* pb = &f;" ?0 _& t* B4 c

如果反过来,你要访问指针变量指向的数据,那么你就要使用取值运算符*,如:

printf("%c, %d\n", *pa, *pb);
" H: J- L4 @% v0 a

这里你可能发现,定义指针的时候也使用了*,这里属于符号的「重用」,也就是说这种符号在不同的地方就有不同的用意:在定义的时候表示「定义一个指针变量」,在其他的时候则用来「获取指针变量指向的变量的值」

直接通过变量名来访问变量的值称之为直接访问,通过指针这样的形式访问称之为间接访问,因此取值运算符有时候也成为「间接运算符」

比如:

//Example 01; G. u3 _, H7 ?% t" {
//代码来源于网络,非个人原创' [$ I' w! i1 Q& D( D9 T
#include <stdio.h>5 Q) s& Z1 y7 t
int main(void)
% P& b0 a& A9 l, o( I/ e5 U{
7 }# _. U/ e" f# Q, V0 N    char a = 'f';
* N$ i9 h8 l- e) t( M    int f = 123;& \0 ~4 C1 \' s- E: Z; r
    char* pa = &a;
. t+ [% Y, i2 C+ _7 u    int* pf = &f;
9 S0 @1 z$ U2 }. J$ c. J   
7 S, F+ P  Y% v5 g. i    printf("a = %c\n", *pa);
0 f8 u7 H% i7 M3 c% P( z$ z" @3 o$ R' \    printf("f = %d\n", *pf);
$ J! `$ [- l# Z2 O( |- W6 U   
" ^! s) |5 F4 q6 L+ y    *pa = 'c';
( W# ^* w+ u$ y: H% ~( f    *pf += 1;
6 A/ I9 O+ G7 q1 V   
5 k. p! \. U. a9 n9 `0 E    printf("now, a = %c\n", *pa);9 [2 R" P9 j+ k( m! e* x& S
    printf("now, f = %d\n", *pf);* X, K/ W# r- {- m+ \
   
0 x. Q' }# o' `% m    printf("sizeof pa = %d\n", sizeof(pa));9 J8 j1 s* b) q4 F. T2 ]
    printf("sizeof pf = %d\n", sizeof(pf));
: `1 u  t" {/ ?( e) |    ' B5 D' ?" l: l' k& b
    printf("the addr of a is: %p\n", pa);
$ F6 C9 T* ~3 H* q) `" t6 a- a! d  l    printf("the addr of f is: %p\n", pf);9 I4 L% A4 m0 {& t
   
' O) O* P- `7 j+ E# l    return 0;7 ?9 ~$ a# L2 E' l9 _
}2 C1 h. A- H6 O/ K3 I! \6 t

程序实现如下:

//Consequence 011 v  o2 A4 }# c: G. u
a = f
$ _0 |( C: u! p( b8 K. mf = 123
* b8 b+ \- A6 m  V6 p7 T' ^now, a = c
) [9 y3 E4 f' ~5 Qnow, f = 124
  M- l4 v, j- b. I9 ?sizeof pa = 4: \& [( h6 j3 e. I8 P/ {
sizeof pf = 4
. @% w6 p( J0 y4 Gthe addr of a is: 00EFF97F' d, }* B7 y+ f  f+ t- `
the addr of f is: 00EFF970$ S% [9 |7 ?, |* p
避免访问未初始化的指针void f()
; b9 V! i* }9 @$ h{
1 D2 v% M8 X* E    int* a;
: A# S9 [) O( f    *a = 10;
* z" ^( `! v& B- @- E! `}
* U5 G9 m1 n; T, m0 e* S3 Q

像这样的代码是十分危险的。因为指针a到底指向哪里,我们不知道。就和访问未初始化的普通变量一样,会返回一个「随机值」。但是如果是在指针里面,那么就有可能覆盖到「其他的内存区域」,甚至可能是系统正在使用的「关键区域」,十分危险。不过这种情况,系统一般会驳回程序的运行,此时程序会被「中止」「报错」。要是万一中奖的话,覆盖到一个合法的地址,那么接下来的赋值就会导致一些有用的数据被「莫名其妙地修改」,这样的bug是十分不好排查的,因此使用指针的时候一定要注意初始化。

指针和数组

有些读者可能会有些奇怪,指针和数组又有什么关系?这俩货明明八竿子打不着井水不犯河水。别着急,接着往下看,你的观点有可能会改变。

数组的地址

我们刚刚说了,指针实际上就是变量在「内存中的地址」,那么如果有细心的小伙伴就可能会想到,像数组这样的一大摞变量的集合,它的地址是啥呢?

我们知道,从标准输入流中读取一个值到变量中,用的是scanf函数,一般貌似在后面都要加上&,这个其实就是我们刚刚说的「取地址运算符」。如果你存储的位置是指针变量的话,那就不需要。

//Example 02
8 `/ k2 \8 K/ {  ?int main(void)
; S) E6 ^0 n8 o; ^7 p{
6 h9 Y) v$ I, `, s1 R2 c    int a;
+ b$ K) B% l" ]- C$ \) G    int* p = &a;+ c6 w) B9 {0 y  D
   
! }4 T5 |8 K. x  x    printf("请输入一个整数:");/ r/ {3 [# K2 _
    scanf("%d", &a);//此处需要&% H5 r1 E6 ?/ u( ?/ g5 ^
    printf("a = %d\n", a);* s2 H5 Z$ c. N& U2 ~: p
   
# M( s+ |( \) p3 I" m    printf("请再输入一个整数:");3 w. C& O& e! S3 @2 }" K% m5 r
    scanf("%d", p);//此处不需要&
3 |$ Q: {3 n  E0 G    printf("a = %d\n", a);3 p  h7 ]% L- [' H' E+ m0 ]/ C
    1 s+ C6 L* Z8 l; G0 g0 ?
    return 0;
! K0 B! H8 e5 i6 d' E) U, f}* P: k' G/ C' |4 k5 C

程序运行如下:

//Consequence 02  `# L' @: r0 }) p$ H
请输入一个整数:1
& Q) y( u% Y% Z4 {' V- na = 12 V7 r9 o; U, b8 i
请再输入一个整数:2
1 D8 F- B8 l# D6 e5 n+ k4 ea = 2
/ t4 q! q1 z! c* a' Z

在普通变量读取的时候,程序需要知道这个变量在内存中的地址,因此需要&来取地址完成这个任务。而对于指针变量来说,本身就是「另外一个」普通变量的「地址信息」,因此直接给出指针的值就可以了。

试想一下,我们在使用scanf函数的时候,是不是也有不需要使用&的时候?就是在读取「字符串」的时候:

//Example 032 `; E3 r" x8 g" P* B
#include <stdio.h>
' C0 a( D6 H: g3 p) U8 h) sint main(void)
# {+ `4 B  F  k, s* q{
$ |) T8 P3 a' |# Z    char url[100];  \: Q' r7 C" _. O7 \- d9 \
    url[99] = '\0';
& e( r2 ?4 z& m  e9 C    printf("请输入TechZone的域名:");- {' ]- J# {: G# A! }6 ~
    scanf("%s", url);//此处也不用&
( i6 X% @1 J! ~+ Q    printf("你输入的域名是:%s\n", url);2 l5 A0 n* }, b9 W. `8 [' ?
    return 0;7 C, r, Y: h1 T
}
0 R$ N- z' O5 x8 f+ T( s. d3 H

程序执行如下:

//Consequence 03
+ G, W/ e1 N: \, W请输入TechZone的域名:www.techzone.ltd. p+ [2 T; ]6 [
你输入的域名是:www.techzone.ltd
: [/ @- n& x2 V) V; d' h

因此很好推理:数组名其实就是一个「地址信息」,实际上就是数组「第一个元素的地址」。咱们试试把第一个元素的地址和数组的地址做个对比就知道了:

//Example 03 V2
6 h& c' J5 F$ [; R; c5 L6 {#include <stdio.h>& \2 u6 S9 M3 R5 {. {
int main(void)3 i! a2 V" n( V$ a
{
2 m, h% t7 p, r0 R    char url[100];% k4 `2 ]) t8 a
    printf("请输入TechZone的域名:");6 [3 m$ F0 B0 J7 _
    url[99] = '\0';
' t5 b  W( z) j1 H1 W    scanf("%s", url);8 r* q+ C% r7 u2 L. T$ }
    printf("你输入的域名是:%s\n", url);3 r! E. \4 @5 T
  Q' e) x$ P, ~: V! G
    printf("url的地址为:%p\n", url);& w7 p- ^9 e# z2 x. c
    printf("url[0]的地址为:%p\n", &url[0]);
0 {) t$ j/ f2 \" ~8 f: {0 g2 m4 o3 R1 }8 v
    if (url == &url[0])
: U: J3 p) v6 D: D    {
" }- H+ _- V$ a        printf("两者一致!");& K( j8 E2 [- O9 e$ X4 Q
    }
0 n' p+ @( c" r) l3 C7 h8 V0 Q    else! r! H/ L: h4 B- M5 ^
    {% W  Q( p+ z2 [& M
        printf("两者不一致!");
6 j0 V% A. y/ w/ v  q6 r& \9 I" C    }
+ W8 @) f8 b# ]* ^& J+ T' [1 y    return 0;
# H+ u* q& [4 l0 V' }1 T% m}: o) m; d9 G$ j1 @3 l. y

程序运行结果为:

//Comsequense 03 V2! y$ _- {! v/ Q4 B* L
请输入TechZone的域名:www.techzone.ltd- v; o+ V2 ~$ @' {8 h& J
你输入的域名是:www.techzone.ltd! @- a. Q9 B: u
url的地址为:0063F804
  N& B; j' w# y4 V2 surl[0]的地址为:0063F804
. b" m* e5 P. m$ z3 m( e两者一致!
" v+ ^% o' w, N: ^" L; |

这么看,应该是实锤了。那么数组后面的元素也就是依次往后放置,有兴趣的也可以自己写代码尝试把它们输出看看。

指向数组的指针

刚刚我们验证了数组的地址就是数组第一个元素的地址。那么指向数组的指针自然也就有两种定义的方法:

...
, ?6 f; {1 Y8 B; z6 c; c3 X/ k7 X- ]3 Jchar* p;4 [+ w! D& r. s+ m! m! {
//方法1+ V4 U6 i$ @) o7 w1 C- s# W/ Z: p
p = a;# M  p( m5 F1 G# O4 t7 ~
//方法2! }- t, Z7 _' V9 v, I/ H  ^# w$ D
p = &a[0];
+ M7 P& V  M5 Z& S4 {2 M* x
指针的运算

当指针指向数组元素的时候,可以对指针变量进行「加减」运算,+n表示指向p指针所指向的元素的「下n个元素」,-n表示指向p指针所指向的元素的「上n个元素」。并不是将地址加1。

如:

//Example 04
9 d" `& c# _% Z" }! P#include <stdio.h>4 X1 [4 e) W. Y' e: a
int main(void)
: ~8 ~2 R+ u: D8 d/ `, X1 d9 X{
7 p7 u2 ?1 F3 J, H/ j6 V3 ?, M    int a[] = { 1,2,3,4,5 };0 s  N( X1 K# P: g7 y4 X
    int* p = a;" J7 n4 ~) ^2 `0 b  [( t
    printf("*p = %d, *(p+1) = %d, *(p+2) = %d\n", *p, *(p + 1), *(p + 2));
: f2 q# y+ y; i! {    printf("*p -> %p, *(p+1) -> %p, *(p+2) -> %p\n", p, p + 1, p + 2);: n! j( s! G# i0 k2 G4 U& M
    return 0;
; K/ w+ X6 o# y. F7 U8 C}7 k" }9 s/ R  j, U2 w' `2 m3 i

执行结果如下:

//Consequence 04- ~' x7 f& L$ g& _5 h. s1 c
*p = 1, *(p+1) = 2, *(p+2) = 3
% ]& e' F! W) G& ?( _( n6 V*p -> 00AFF838, *(p+1) -> 00AFF83C, *(p+2) -> 00AFF840
" U$ X  z+ [# n" E7 O- [

有的小伙伴可能会想,编译器是怎么知道访问下一个元素而不是地址直接加1呢?

其实就在我们定义指针变量的时候,就已经告诉编译器了。如果我们定义的是整型数组的指针,那么指针加1,实际上就是加上一个sizeof(int)的距离。相对于标准的下标访问,使用指针来间接访问数组元素的方法叫做指针法。

其实使用指针法来访问数组的元素,不一定需要定义一个指向数组的单独的指针变量,因为数组名自身就是指向数组「第一个元素」的指针,因此指针法可以直接作用于数组名:

...+ Y1 |. D# [  g4 _
printf("p -> %p, p+1 -> %p, p+2 -> %p\n", a, a+1, a+2);
9 d" F; G) R- F5 o+ M2 Wprintf("a = %d, a+1 = %d, a+2 = %d", *a, *(a+1), *(a+2));
2 I, @& s) n1 x! K...
! `" V  X8 C+ p* L' u3 b* {

执行结果如下:

p -> 00AFF838, p+1 -> 00AFF83C, p+2 -> 00AFF8409 s+ n! m6 P# ?2 j/ C' i
b = 1, b+1 = 2, b+2 = 3- i; J+ H3 _9 e1 P) v; B: |

现在你是不是感觉,数组和指针有点像了呢?不过笔者先提醒,数组和指针虽然非常像,但是绝对「不是」一种东西。

甚至你还可以直接用指针来定义字符串,然后用下标法来读取每一个元素:

//Example 05
( I! i' u* T; V  X! z* ~//代码来源于网络
5 d5 k. d! L7 k/ r( P- \#include <stdio.h>
! e0 J+ K$ i- f: {/ m& {#include <string.h>- j8 V+ ~3 X' n0 F+ B
int main(void)+ ]* C1 l$ P7 N
{  g6 w7 J1 D+ @" b) q
    char* str = "I love TechZone!";$ M% _0 J  x( ?
    int i, length;
& T* R1 g% s& @5 [) L   
5 D7 z! h! R6 s) W    length = strlen(str);1 r9 U8 n  R3 M' U5 Q
   
0 U1 n( |# P. Z4 U( M) i6 q$ T    for (i = 0; i < length, i++)4 l8 F, f2 R7 W9 }4 Y
    {
/ P5 M; L# P  f% G  {        printf("%c", str);
- o, C$ L% B3 P% T    }" w4 B3 K- ]1 S
    printf("\n");
+ V' M/ p2 ?" R: r0 ~- C$ i   
9 p9 W7 G8 H& x% }  x. r7 J. z( ~/ ]    return 0;" S" d, b9 B# _
}
' H' u+ \3 j# U" P  G2 [

程序运行如下:

//Consequence 05: S4 w9 N) k  ^" g2 A! O, d
I love TechZone!
5 M; P+ h7 N7 j

在刚刚的代码里面,我们定义了一个「字符指针」变量,并且初始化成指向一个字符串。后来的操作,不仅在它身上可以使用「字符串处理函数」,还可以用「下标法」访问字符串中的每一个字符。

当然,循环部分这样写也是没毛病的:

...
# h! l7 x; g9 A3 t% Q+ y6 r% ~* bfor (i = 0, i < length, i++)
2 J* y9 z) Z( a( x{7 P; O* B7 X" Z- e
    printf("%c", *(str + i));& r8 h) `1 f$ x( V( }; s0 K
}
* T' L. j1 V" @: R$ |

这就相当于利用了指针法来读取。

指针和数组的区别

刚刚说了许多指针和数组相互替换的例子,可能有的小伙伴又开始说:“这俩货不就是一个东西吗?”

随着你对指针和数组越来越了解,你会发现,C语言的创始人不会这么无聊去创建两种一样的东西,还叫上不同的名字。指针和数组终究是「不一样」的。

比如笔者之前看过的一个例子:

//Example 06
3 c1 j7 f2 \% o$ z//代码来源于网络
& N" m3 \" b8 [' n( \* ^& G#include <stdio.h>. L5 [, F: x  Z) Z. C) m
int main(void)
( D: S" m0 F0 B{
' f) I! z! l+ t3 @  F& v) s    char str[] = "I love TechZone!";+ r  m9 P( V- A6 u$ g+ Q! b
    int count = 0;
" p3 P8 @0 I0 a( q   
. g! d. H1 [. e# T    while (*str++ != '\0')
4 g5 [/ ?4 v; B9 m9 m1 z. g* u    {
$ E, h( a- h$ l, E2 T* B6 N  Q0 K        count++;
* E, {3 J, u/ h. w0 `7 I0 n    }
- @9 f' Y3 f; F    printf("总共有%d个字符。\n", count);
* ^- H* o* Z1 f1 w( r   
2 u6 J! L+ E- L3 |% W' S9 N    return 0;
# V6 c" U% X3 M& t}
' Z6 d( J8 Z: m" A. h

当编译器报错的时候,你可能会开始怀疑你学了假的C语言语法:

//Error in Example 06
3 ~- e7 U6 m. ~( i* O+ p% \错误(活动)        E0137        表达式必须是可修改的左值
* c# w! E: F* T; W( f' e1 L: b错误        C2105        “++”需要左值
0 y: S1 k  C' \

我们知道,*str++ != ‘\0’是一个复合表达式,那么就要遵循「运算符优先级」来看。具体可以回顾《C语言运算符优先级及ASCII对照表》。

str++比*str的优先级「更高」,但是自增运算符要在「下一条语句」的时候才能生效。所以这个语句的理解就是,先取出str所指向的值,判断是否为\0,若是,则跳出循环,然后str指向下一个字符的位置。

看上去貌似没啥毛病,但是,看看编译器告诉我们的东西:表达式必须是可修改的左值

++的操作对象是str,那么str到底是不是「左值」呢?

如果是左值的话,那么就必须满足左值的条件。

  • 拥有用于识别和定位一个存储位置的标识符
  • 存储值可修改
    6 Y7 f7 x: u4 E

第一点,数组名str是可以满足的,因为数组名实际上就是定位数组第一个元素的位置。但是第二点就不满足了,数组名实际上是一个地址,地址是「不可以」修改的,它是一个常量。如果非要利用上面的思路来实现的话,可以将代码改成这样:

//Example 06 V2& v$ O, Z* C$ P- m& q9 {$ k% r- M6 L2 B
//代码来源于网络. ^8 d, y( c/ m; p/ s2 W' d0 P
#include <stdio.h>+ B' q0 |- c/ m# u; I: v
int main(void)* P  x% r% j' q$ W1 h. L0 @
{
! t: D2 V3 f6 `3 w! j% z    char str[] = "I love TechZone!";3 D$ J% J: W9 T
    char* target = str;
0 W5 l& i% j! K- {8 p" A, J    int count = 0;
9 {' r: H6 N+ l7 p) s5 k   
" B7 I* N0 Q' h# J5 E( Q) k    while (*target++ != '\0')
7 ~/ E) S& _2 Q: s" Z2 v    {
  O' L8 R8 c, k9 q1 R1 \        count++;& L$ J* D! m& a! G
    }
2 a7 v% ~: c3 R1 Z& p2 q9 q* l    printf("总共有%d个字符。\n", count);# b; g7 B" F4 d" B2 b
    1 V7 F0 v% ^. P$ h8 K' a; i4 _
    return 0;+ i, w0 l3 F6 T  I4 n
}
( j! I$ v* \- H) \

这样就可以正常执行了:

//Consequence 06 V24 i& ~- w3 V& N& ^
总共有16个字符。. ~& d( w1 i3 K  E7 y

这样我们就可以得出:数组名只是一个「地址」,而指针是一个「左值」

指针数组?数组指针?

看下面的例子,你能分辨出哪个是指针数组,哪个是数组指针吗?

int* p1[5];6 A5 l; \+ z  }6 b( b
int(*p2)[5];
* M! j* n( @& Q) K/ w8 h/ z& }( n

单个的我们都可以判断,但是组合起来就有些难度了。

答案:

int* p1[5];//指针数组, Z2 d6 k1 ?! `- i( o" d8 I; w! y# x
int(*p2)[5];//数组指针/ T9 h! _' }% f6 u# q

我们挨个来分析。

指针数组

数组下标[]的优先级是最高的,因此p1是一个有5个元素的「数组」。那么这个数组的类型是什么呢?答案就是int*,是「指向整型变量的指针」。因此这是一个「指针数组」

那么这样的数组应该怎么样去初始化呢?

你可以定义5个变量,然后挨个取地址来初始化。

不过这样太繁琐了,但是,并不是说指针数组就没什么用。

比如:

//Example 07
. }* u6 v' t6 f  y3 r! g4 u1 p#include <stdio.h>2 r0 p" F7 a1 }& z, K3 ^' x
int main(void); s! R7 c6 C1 s" y4 X
{$ k! t) S( g1 k2 s' i4 v( _
    char* p1[5] = {, W/ K6 C% }: l! d" B+ E; s
        "人生苦短,我用Python。",
( A4 G! o5 G% D4 t& x9 o        "PHP是世界上最好的语言!",
4 J# J9 t+ u7 L, n* x        "One more thing...",- q; `1 r0 p' C  s& L
        "一个好的程序员应该是那种过单行线都要往两边看的人。",
- O0 |' V7 w: ]& S9 @        "C语言很容易让你犯错误;C++看起来好一些,但当你用它时,你会发现会死的更惨。": x; ], m1 a" K; ^0 h! f
    };
* [2 r, F6 s, @/ O$ M* ~  M    int i;
" h5 ^: R, z" \8 L8 F; u4 n* r4 L    for (i = 0; i < 5; i++)/ j8 J, s! E! R2 _# P: w
    {
6 y, m! A- `& _        printf("%s\n", p1);
& o( N. @1 Q2 o+ w    }
# |9 P8 w2 N+ X  U+ \7 R: Y# S    return 0;7 i' M- K1 ?1 M0 p
}
/ e! N7 j0 k3 W/ W+ H9 \

结果如下:

//Consequence 07; X- B6 B) W3 S: A: g  Z
人生苦短,我用Python。6 d$ U1 l6 t/ h% @; L
PHP是世界上最好的语言!( @- }- i5 Y6 X1 l  k# C
One more thing...6 m" e, D: z1 B+ H! v! h3 C* f6 N* @
一个好的程序员应该是那种过单行线都要往两边看的人。
* @& [* o, r5 {5 n, eC语言很容易让你犯错误;C++看起来好一些,但当你用它时,你会发现会死的更惨。
  _; l  v9 |+ e9 k" V, {

这样是不是比二维数组来的更加直接更加通俗呢?

数组指针

()和[]在优先级里面属于「同级」,那么就按照「先后顺序」进行。

int(*p2)将p2定义为「指针」, 后面跟随着一个5个元素的「数组」,p2就指向这个数组。因此,数组指针是一个「指针」,它指向的是一个数组。

但是,如果想对数组指针初始化的时候,千万要小心,比如:

//Example 08" l6 n1 v" U; f+ N; P! |, w* U
#include <stdio.h>
$ \( u- I4 z2 h. k: gint main(void)
( l& C& z8 g; g! F+ p{) b0 w$ d$ T+ S! e9 F& l. Q# w
    int(*p2)[5] = {1, 2, 3, 4, 5};, R+ B  E( E9 _) m# v0 W
    int i;# \; T- p/ b& }8 _) P% F  p7 V
    for (i = 0; i < 5; i++)
2 S% U3 R& G# V1 Y7 {0 R. i3 l    {
0 `2 j, P1 a7 p8 n8 }% n7 o        printf("%d\n", *(p2 + i));7 v* u# k7 J( @8 M+ x7 l2 y4 |7 n* X
    }
8 |" q5 j; Q% \9 J    return 0;
9 N, a* H0 A' K, f}
) t+ P' b/ e! C8 I' q' d+ U

Visual Studio 2019报出以下的错误:

//Error and Warning in Example 08
2 S; F) }8 q! W1 i5 V3 D错误(活动)        E0146        初始值设定项值太多
2 m, B: E  R8 a5 _+ q8 M+ ?# E, p- W; |错误        C2440        “初始化”: 无法从“initializer list”转换为“int (*)[5]”
9 p  R" v6 I3 ]: j警告        C4477        “printf”: 格式字符串“%d”需要类型“int”的参数,但可变参数 1 拥有了类型“int *”* T1 j8 V5 h0 [! w! s3 p

这其实是一个非常典型的错误使用指针的案例,编译器提示说这里有一个「整数」赋值给「指针变量」的问题,因为p2归根结底还是指针,所以应该给它传递一个「地址」才行,更改一下:

//Example 08 V2
: N5 {5 u2 w  k  p* ^: H. \! j#include <stdio.h>' z7 V/ i4 E: y3 E+ T/ K1 E$ z2 T: F
int main(void). S( e$ p# a! U# V2 M
{' V+ \+ C- F) e. h& G1 M
    int temp[5] = {1, 2, 3, 4, 5};
; J6 D4 g9 i6 W    int(*p2)[5] = temp;
7 P1 t: g1 B- F( E8 l4 ~    int i;
3 d3 d' n$ F( \+ d/ }    for (i = 0; i < 5; i++)) U& f* g* L7 p$ Y" q  ?; R; u
    {) M0 `! F2 @  O/ p
        printf("%d\n", *(p2 + i));9 i! b& I; M) r( z& \( M$ R* t
    }
' B( J$ _" b# k  Q    return 0;- a* S$ J: a& l/ Y( v. u3 ~
}
5 Y# q4 {$ ^8 m* t, a2 _
//Error and Warning in Example 08 V2
5 z- e( l0 C2 O2 a错误(活动)        E0144        "int *" 类型的值不能用于初始化 "int (*)[5]" 类型的实体" ]/ o# k  n, J% x$ `; f
错误        C2440        “初始化”: 无法从“int [5]”转换为“int (*)[5]”
. |( n8 v9 L9 C7 E" O: X警告        C4477        “printf”: 格式字符串“%d”需要类型“int”的参数,但可变参数 1 拥有了类型“int *”* G% ~" n. x  ]4 v2 l

可是怎么还是有问题呢?

我们回顾一下,指针是如何指向数组的。

int temp[5] = {1, 2, 3, 4, 5};
6 C( Q8 j8 l* Q; v2 Q0 T+ _5 H, I# Zint* p = temp;
' u3 N- B6 K: j$ J

我们原本以为,指针p是指向数组的指针,但是实际上「并不是」。仔细想想就会发现,这个指针实际上是指向的数组的「第一个元素」,而不是指向数组。因为数组里面的元素在内存中都是挨着个儿存放的,因此只需要知道第一个元素的地址,就可以访问到后面的所有元素。

但是,这么来看的话,指针p指向的就是一个「整型变量」的指针,并不是指向「数组」的指针。而刚刚我们用的数组指针,才是指向数组的指针。因此,应该将「数组的地址」传递给数组指针,而不是将第一个元素的地址传入,尽管它们值相同,但是「含义」确实不一样:

//Example 08 V3
3 n! Q7 E8 e* o8 M//Example 08 V2
" D  l9 O! ?2 i  y' R6 L2 c( w#include <stdio.h>. A, k  y* j$ V# V. ]! r
int main(void)0 r; P8 M. H% Y% S' {
{
( ]5 a. d4 w7 Q+ e    int temp[5] = {1, 2, 3, 4, 5};
# l2 @8 k- e3 N0 [9 w+ A    int(*p2)[5] = &temp;//此处取地址
1 C$ X2 C& ]  E' @" t9 @3 C6 b    int i;
& o/ \" ]  e/ W3 g  B! y! ?    for (i = 0; i < 5; i++)5 f' z/ _; I4 _! c4 s0 I& `
    {
7 Y# n* ~  l7 A, p3 N, S& A$ j3 @        printf("%d\n", *(*p2 + i));' ~) K: a* L' D/ v* _3 y
    }6 ~6 ?* H! K5 C; r1 J. f
    return 0;
4 U& b8 F" G. B8 ^6 M$ D}$ A2 J" q. Q4 D8 P$ h: W: g

程序运行如下:

//Consequence 08/ B* `% b1 M9 ^5 v  G
1& `9 a3 \: p4 z; _  S$ B2 K
23 h9 K. A4 O( N4 O- v4 C
3; V% H: X" n9 x, u8 T9 ^$ G3 F1 {
4
, D% }+ {2 C) k5- \3 T4 [( |' O* L
指针和二维数组

在上一节《C语言之数组》我们讲过「二维数组」的概念,并且我们也知道,C语言的二维数组其实在内存中也是「线性存放」的。

假设我们定义了:int array[4][5]

array

array作为数组的名称,显然应该表示的是数组的「首地址」。由于二维数组实际上就是一维数组的「线性拓展」,因此array应该就是指的指向包含5个元素的数组的指针。

如果你用sizeof()去测试array和array+1的话,就可以测试出来这样的结论。

*(array+1)

首先从刚刚的问题我们可以得出,array+1同样也是指的指向包含5个元素的数组的指针,因此*(array+1)就是相当于array[1],而这刚好相当于array[1][0]的数组名。因此*(array+1)就是指第二行子数组的第一个元素的地址。

*(*(array+1)+2)

有了刚刚的结论,我们就不难推理出,这个实际上就是array[1][2]。是不是感觉非常简单呢?

总结一下,就是下面的这些结论,记住就好,理解那当然更好:

*(array + i) == array6 h- B4 M' [( \8 Y* o- o. F# a/ v
*(*(array + i) + j) == array[j]
5 C& x* Z0 G; X/ P*(*(*(array + i) + j) + k) == array[j][k]
  |. C  U6 _2 f6 x) @, ~$ [, R3 g...$ b/ A- p9 d2 X8 C
数组指针和二维数组

我们在上一节里面讲过,在初始化二维数组的时候是可以偷懒的:

int array[][3] = {
( B1 l! J$ O, V1 G    {1, 2, 3},
" {( X' g! p& P8 j0 d+ T8 H    {4, 5, 6}; i2 i) N+ o3 x4 f- i* Y
};
( p( l; A7 g: Z

刚刚我们又说过,定义一个数组指针是这样的:

int(*p)[3];
' s, d# d. D: O8 J

那么组合起来是什么意思呢?

int(*p)[3] = array;
8 Z4 t! q1 H4 ~& u; Y! G  Z$ H/ J

通过刚刚的说明,我们可以知道,array是指向一个3个元素的数组的「指针」,所以这里完全可以将array的值赋值给p。

其实C语言的指针非常灵活,同样的代码用不同的角度去解读,就可以有不同的应用。

那么如何使用指针来访问二维数组呢?没错,就是使用「数组指针」

//Example 09
% i* m% _8 a3 M. T$ X7 z" f" g#include <stdio.h>
1 |  p( F+ X7 J* @4 `int main(void)
4 ]/ |3 Y* |* K! ^+ T# S, \5 R{3 X- Y3 y1 \3 }* z: f
    int array[3][4] = {% `2 H3 v5 @. b& d; E' R
        {0, 1, 2, 3},
: u- \: {% h7 u* G: Q* J, `        {4, 5, 6, 7},
0 P. b; i( z/ f" b; T        {8, 9, 10, 11}: e0 C1 s! Y8 m1 }" K! }
    };" V1 ^9 s7 T+ H$ G+ u
    int(*p)[4];
: \! |' [% }( D5 t* m: H* Q& m    int i, j;
9 m# G7 L: F" i& y/ ]" B    p = array;& ^* \( {0 W' j* s- v2 |; f
    for (i = 0, i < 3, i++)
* x& v% Z% I+ e5 w    {2 s# ?* W' A; h( l. h* A' ~$ p
        for (j = 0, j < 4, j++)2 Y: B$ j  Y$ M1 ^7 q0 ?
        {& m* c$ j( M! q% N, r3 P' A; u: p
            printf("%2d ", *(*(p+i) + j));
+ S9 c& @8 q' }) W6 w, p  j6 i$ F! O( q        }7 i. `( s8 I! \# d
        printf("\n");+ [8 f  Z& [# C) w& t% y
    }1 N1 l9 \$ E/ Z+ ~- B5 e; W+ W) m/ ~
    return 0;
+ f$ Y6 F- K1 z0 t7 I+ P5 G}
  h3 O0 x! p4 }6 R( f

运行结果:

//Consequence 09
: x. V$ }# d- J' ?0 x, {: Z5 x0 1 2 3& H# E5 D2 d/ K- \1 A2 Y% w
4 5 6 7) v: C+ A# v) i( g
8 9 10 11: O* Q7 X( F( b: J' c% l% Z
void指针

void实际上是无类型的意思。如果你尝试用它来定义一个变量,编译器肯定会「报错」,因为不同类型所占用的内存有可能「不一样」。但是如果定义的是一个指针,那就没问题。void类型中指针可以指向「任何一个类型」的数据,也就是说,任何类型的指针都可以赋值给void指针。

将任何类型的指针转换为void是没有问题的。但是如果你要反过来,那就需要「强制类型转换」。此外,不要对void指针「直接解引用」,因为编译器其实并不知道void指针会存放什么样的类型。

//Example 10
2 e8 O% R' S! G) J- e, j#include <stdio.h>9 E- {3 u$ O" U7 L8 m- c1 X) K1 L
int main(void)+ ]" t3 I- G- h, J' \
{0 ]9 N6 E. j" j& R
    int num = 1024;9 x6 @* F1 y% u  P5 X, L
    int* pi = &num;8 y: Q& U4 d1 c0 u; Z5 Q, z
    char* ps = "TechZone";
2 g5 H, I- H/ M% H; o+ R    void* pv;
! k- g( {( f' f" ~  w' K: X    8 A7 z7 c5 [; \1 x6 O) x/ T9 F
    pv = pi;) D: t2 M$ U& n- n. @: H/ f1 E
    printf("pi:%p,pv:%p\n", pi, pv);
3 T. X5 L) h1 `/ ?, q8 d* X1 \2 w" _    printf("*pv:%d\n", *pv);
7 u, o( `3 W: o& L1 q# X   
) t' |$ g8 d( C: @    pv = ps;3 g0 ~7 A2 M/ M* m
    printf("ps:%p,pv:%p\n", ps, pv);& F# {1 c( ]) R+ g- q: e
    printf("*pv:%s\n", *pv);& V- v3 o3 Y% P
}
( ]$ V' {  K5 s4 d& l$ A

这样会报错:

//Error in Example 10
0 y  U% T1 S# N7 ?3 A& t错误        C2100        非法的间接寻址+ Z0 z$ `" ~( e8 J9 {' D
错误        C2100        非法的间接寻址* |( N( j+ H0 u' _  `' l; O

如果一定要这么做,那么可以用「强制类型转换」

//Example 10 V2
3 \$ g- E* k6 g& r6 ]3 e0 K9 e#include <stdio.h>0 a- e$ x7 h+ z* K
int main(void); ^) B5 K! c: r
{6 T% B6 V7 S! q; X# W) D
    int num = 1024;
: V5 p4 {6 T; G9 U& ?, w5 r: P    int* pi = &num;# I+ p/ K# t+ y4 u- y8 P
    char* ps = "TechZone";2 C6 m! P/ d5 T0 @5 ]' Q5 T4 J
    void* pv;
& e# ^1 p$ N: n  Z* P3 g
% L+ r1 ~) n; c# w8 G    pv = pi;
6 B% V5 @2 M( X    printf("pi:%p,pv:%p\n", pi, pv);' v+ _/ t; }) P; ^0 Y8 r! C- e8 Y
    printf("*pv:%d\n", *(int*)pv);
- ^7 c8 t$ k' t* u. Q/ G! U, |9 z8 U% j$ e
    pv = ps;) r- C- @1 ~4 G8 v8 n/ }6 l3 I
    printf("ps:%p,pv:%p\n", ps, pv);
2 `% ^& p- X/ I1 H    printf("*pv:%s\n", pv);
, c) V+ }& D5 `* k}
, U+ C' b4 i4 y- ~3 F' y+ I

当然,使用void指针一定要小心,由于void指针几乎可以「通吃」所有类型,所以间接使得不同类型的指针转换变得合法,如果代码中存在不合理的转换,编译器也不会报错。

因此,void指针能不用则不用,后面讲函数的时候,还可以解锁更多新的玩法。

NULL指针

在C语言中,如果一个指针不指向任何数据,那么就称之为「空指针」,用「NULL」来表示。NULL其实是一个宏定义:

#define NULL ((void *)0)
. v$ S' ]) E' p$ [+ |2 ?& [/ X  x

在大部分的操作系统中,地址0通常是一个「不被使用」的地址,所以如果一个指针指向NULL,就意味着不指向任何东西。为什么一个指针要指向NULL呢?

其实这反而是一种比较指的推荐的「编程风格」——当你暂时还不知道该指向哪儿的时候,就让它指向NULL,以后不会有太多的麻烦,比如:

//Example 11+ y  c0 F; ~9 A" s6 q4 B
#include <stdio.h>
9 H% a# u, B1 I$ kint main(void)' `% Y" a& `9 l3 j1 t5 i% Q
{) f: S4 V' I: k1 P; L' E% S8 g
    int* p1;0 V, `3 s% v* Q; \( i
    int* p2 = NULL;! @6 F7 _7 c: B/ {% p; U* d
    printf("%d\n", *p1);- {8 [& j) y% @# l( [
    printf("%d\n", *p2);
/ I* z1 A+ @. I4 h    return 0;
$ W8 _$ d( R7 l# h3 y# d2 G}" [' {' v, Q/ y! c1 R, E

第一个指针未被初始化。在有的编译器里面,这样未初始化的变量就会被赋予「随机值」。这样指针被称为「迷途指针」「野指针」或者「悬空指针」。如果后面的代码对这类指针解引用,而这个地址又刚好是合法的话,那么就会产生莫名其妙的结果,甚至导致程序的崩溃。因此养成良好的习惯,在暂时不清楚的情况下使用NULL,可以节省大量的后期调试的时间。

指向指针的指针

开始套娃了。其实只要你理解了指针的概念,也就没什么大不了的。

//Example 12
! `: t# L8 Y. j$ U#include <stdio.h>4 V# _* V% y4 g) \2 q+ F  V. `
int main(void). l- e" Q/ G6 ]" u0 J
{
7 t0 u6 ]$ G2 N" h% _9 G; O2 o- a    int num = 1;" a& Z( ^0 H3 g' e% A
    int* p = &num;0 I! `: @0 s, ]( E) x* Z
    int** pp = &p;
3 w6 o. N6 \. c. [2 P   
6 |  a5 w* D$ X6 ^/ Q    printf("num: %d\n", num);, X6 F  y( U9 l3 s; ~
    printf("*p: %d\n", *p);- D3 j6 _) W0 n* E9 @
    printf("**p: %d\n", **pp);
8 F" Y$ V( c( Z* x    printf("&p: %p, pp: %p\n", &p, pp);
$ G+ I) U& L6 l! W  w- K+ e2 m8 C    printf("&num: %p, p: %p, *pp: %p\n", &num, p, *pp);
: y. f2 J$ u) f+ W9 s    return 0;
/ V5 f% h; P( X9 S7 f9 y}% b6 O( H8 e9 G* A: a$ G& i  h

程序结果如下:

//Consequence 12
6 [# U9 c0 B( `9 mnum: 1
% ]5 M  j, i0 t9 g! u, ?; G*p: 1" |8 o$ S6 m! N! R' u4 E' a
**p: 1, f) e4 g$ Z5 g% p& [
&p: 004FF960, pp: 004FF960: j* S" O! P3 @- u
&num: 004FF96C, p: 004FF96C, *pp: 004FF96C; r1 z) ?2 f$ @9 h2 R; S- F/ h' k

当然你也可以无限地套娃,一直指下去。不过这样会让代码可读性变得「很差」,过段时间可能你自己都看不懂你写的代码了。

指针数组和指向指针的指针

那么,指向指针的指针有什么用呢?

它可不是为了去创造混乱代码,在一个经典的实例里面,就可以体会到它的用处:

char* Books[] = {
& X4 I* P7 h9 X: R7 m1 e3 h" I    "《C专家编程》",
# P! q  M6 p( [' G. V    "《C和指针》",
7 f% Y3 r$ n3 b( g$ g; A% `    "《C的陷阱与缺陷》",# x! m* n% @4 r$ _7 t
    "《C Primer Plus》",2 \0 t% H3 M& h9 u
    "《Python基础教程(第三版)》"1 M4 k, O2 o8 b' p
};+ c0 w" K" D/ Z! ?2 ^* D8 d

然后我们需要将这些书进行分类。我们发现,其中有一本是写Python的,其他都是C语言的。这时候指向指针的指针就派上用场了。首先,我们刚刚定义了一个指针数组,也就是说,里面的所有元素的类型「都是指针」,而数组名却又可以用指针的形式来「访问」,因此就可以使用「指向指针的指针」来指向指针数组:

...+ o) ^) {$ c3 N2 f: o3 n0 s( n
char** Python;
  X0 ]2 U4 [( S* [! m$ v/ uchar** CLang[4];% \* Z# E& n' }2 J

* r1 L" a4 o7 l4 Q3 a( Q, ePython = &Books[5];, k' ?$ q6 Z/ T) o3 o7 ]7 v
CLang[0] = &Books[0];
9 F4 T& N3 e/ k' f& d& p2 HCLang[1] = &Books[1];- f  U: _/ z2 {" u, }
CLang[2] = &Books[2];* T7 g! u; e$ Y; ?9 Y: g
CLang[3] = &Books[3];
# v% V( i; k$ U3 q...
1 o$ a; U9 \6 [7 S/ }2 }

因为字符串的取地址值实际上就是其「首地址」,也就是一个「指向字符指针的指针」,所以可以这样赋值。

这样,我们就利用指向指针的指针完成了对书籍的分类,这样既避免了浪费多余的内存,而且当其中的书名要修改,只需要改一次即可,代码的灵活性和安全性都得到了提升。

常量和指针

常量,在我们目前的认知里面,应该是这样的:

520, 'a'& W" ?" P4 ~9 a- ~) b6 b7 _

或者是这样的:

#define MAX 10002 `  r  E8 p+ J, J
#define B 'b'
6 n  ~+ t/ j( n+ V- p2 z+ S

常量和变量最大的区别,就是前者「不能够被修改」,后者可以。那么在C语言中,可以将变量变成像具有常量一样的特性,利用const即可。

const int max = 1000;
# Y! T* z) K& c$ q8 L$ c) S' V+ ~! `7 aconst char a = 'a';0 M, I" t" I8 F; i( _; t

在const关键字的作用下,变量就会「失去」本来具有的可修改的特性,变成“只读”的属性。

指向常量的指针

强大的指针当然也是可以指向被const修饰过的变量,但这就意味着「不能通过」指针来修改它所引用的值。总结一下,就是以下4点:

  • 指针可以修改为指向不同的变量
  • 指针可以修改为指向不同的常量
  • 可以通过解引用来读取指针指向的数据
  • 不可以通过解引用来修改指针指向的数据; J' L3 T( H; {4 R" a# X
常量指针指向非常量的常量指针

指针本身作为一种「变量」,也是可以修改的。因此,指针也是可以被const修饰的,只不过位置稍稍「发生了点变化」

...
4 Q! l1 L" W6 {' Iint* const p = &num;
& M: X/ V) X4 g% a1 B% v, F# O...% W% O6 _8 ^) v; f, c  v

这样的指针有如下的特性:

  • 指针自身不能够被修改
  • 指针指向的值可以被修改
    8 {5 Y8 N9 D8 o+ P4 @8 O0 a# i0 d
指向常量的常量指针

在定义普通变量的时候也用const修饰,就得到了这样的指针。不过由于限制太多,一般很少用到:

...
9 D6 Q0 X! r* O1 Tint num = 100;
3 S- o7 Z7 i- d: P0 f# qconst int cnum = 200;
/ `2 J: D" S) qconst int* const p = &cnum;
2 }2 j0 O) K: {+ @...

/ V. a' V& g' z) R% I- N# L! ]
收藏 评论0 发布时间:2020-11-4 18:53

举报

0个回答

所属标签

关于
我们是谁
投资者关系
意法半导体可持续发展举措
创新与技术
意法半导体官网
联系我们
联系ST分支机构
寻找销售人员和分销渠道
社区
媒体中心
活动与培训
隐私策略
隐私策略
Cookies管理
行使您的权利
官方最新发布
STM32N6 AI生态系统
STM32MCU,MPU高性能GUI
ST ACEPACK电源模块
意法半导体生物传感器
STM32Cube扩展软件包
关注我们
st-img 微信公众号
st-img 手机版