10.1 ที่อยู่และ pointer
ตั้งแต่หน้า 1.1 น้องเขียน scanf("%d", &age) มานับครั้งไม่ถ้วน โดยรู้แค่ว่า &age คือที่อยู่ของ age หน้านี้จะเปิดดูว่าที่อยู่หน้าตาเป็นอย่างไร เก็บไว้ในตัวแปรได้อย่างไร และใช้ที่อยู่ไปอ่านหรือแก้ตัวแปรได้อย่างไร ตัวแปรที่เก็บที่อยู่เรียกว่า pointer (ตัวชี้) เป็นเครื่องมือที่ทำให้ฟังก์ชันแก้ตัวแปรของคนเรียกได้ จองอาเรย์ตามขนาดที่ข้อมูลเข้าบอกได้ และเป็นฐานของ linked list ในบทที่ 14
หน่วยความจำมีเลขที่บ้าน
ในหน้า 1.3 เห็นแล้วว่า int ใช้ 4 byte หน่วยความจำของเครื่องคือ byte เรียงกันเป็นแถวยาวมาก แต่ละ byte มีเลขประจำตัวไม่ซ้ำกัน เรียกว่า ที่อยู่ (address) เหมือนบ้านเลขที่ในซอยที่ยาวมากๆ ตัวแปร int หนึ่งตัวจองบ้านติดกัน 4 หลัง และที่อยู่ของตัวแปรคือเลขที่ของหลังแรก
ใส่ & หน้าชื่อตัวแปรจะได้ที่อยู่ของตัวแปรนั้น และ printf พิมพ์ที่อยู่ได้ด้วย %p ซึ่งมาจากคำว่า pointer
#include <stdio.h>
int main()
{
int score = 85;
int age = 16;
printf("score = %d is at %p\n", score, &score);
printf("age = %d is at %p\n", age, &age);
return 0;
}
score = 85 is at 0000009F2D5FFBCC
age = 16 is at 0000009F2D5FFBC8
ผลนี้มาจากเครื่องที่เขียนเอกสารนี้ และเปลี่ยนทุกครั้งที่รัน รันสามครั้งได้ที่อยู่ของ score เป็น 0000009F2D5FFBCC 000000F7A27FFD3C และ 000000892B7FF6EC ไม่ซ้ำกันเลย เพราะ Windows สุ่มตำแหน่งวางโปรแกรมใหม่ทุกครั้ง เพื่อให้โปรแกรมไม่ประสงค์ดีเดาตำแหน่งข้อมูลได้ยาก น้องรันเองก็จะได้เลขอื่น ไม่ใช่เรื่องผิดปกติ
ที่อยู่พิมพ์ออกมาเป็น เลขฐานสิบหก ซึ่งใช้หลัก 0 ถึง 9 แล้วต่อด้วย A ถึง F แทนค่า 10 ถึง 15 เลขฐานจะได้เรียนในบทที่ 12 ตอนนี้สังเกตแค่หลักท้าย age ลงท้ายด้วย 8 score ลงท้ายด้วย C ซึ่งคือ 12 ห่างกัน 4 พอดี เท่ากับขนาดของ int หนึ่งตัว สองตัวแปรนี้จึงวางชิดกัน ส่วนตัวไหนอยู่ก่อนขึ้นกับ compiler อย่าเขียนโปรแกรมที่ต้องพึ่งลำดับนี้
ตัวแปรที่เก็บที่อยู่
ที่อยู่ก็เป็นค่าค่าหนึ่ง เก็บใส่ตัวแปรได้ ตัวแปรที่เก็บที่อยู่ของ int ประกาศแบบนี้
int *p = &score;
อ่านว่า p มีชนิดเป็น int * คือ pointer ไปยัง int และค่าเริ่มต้นคือที่อยู่ของ score ดอกจันตรงนี้เป็นส่วนหนึ่งของชนิด ไม่ใช่การคูณ เวลาพูดเราบอกว่า p ชี้ไปที่ score
#include <stdio.h>
int main()
{
int score = 85;
int *p = &score;
printf("&score = %p\n", &score);
printf("p = %p\n", p);
printf("&p = %p\n", &p);
return 0;
}
&score = 000000A04A9FF94C
p = 000000A04A9FF94C
&p = 000000A04A9FF940
ค่าที่เก็บใน p ตรงกับที่อยู่ของ score ทุกหลัก และ p เองก็เป็นกล่องในหน่วยความจำที่มีที่อยู่ของตัวเอง แผนภาพข้างล่างวาดจากการรันครั้งนี้ ตัวเลขสีจางใต้กล่องคือที่อยู่ของกล่องนั้น แสดงแค่สี่หลักท้ายให้อ่านง่าย
ลูกศรไม่ได้มีอยู่จริงในหน่วยความจำ ในกล่อง p มีแค่ตัวเลข แต่ตัวเลขนั้นบอกว่าให้ไปที่กล่องไหน เราจึงวาดเป็นลูกศรให้เห็นภาพ ต่อจากนี้จะวาดแบบนี้ตลอดบท
ใช้ * ตามไปที่กล่อง
มีที่อยู่แล้ว ถ้าอยากอ่านหรือเขียนกล่องที่อยู่ตรงนั้น ให้ใส่ * หน้าชื่อ pointer การทำแบบนี้เรียกว่า dereference (ตามที่อยู่ไป) *p แปลว่า "กล่องที่ p ชี้อยู่" ตอนนี้ก็คือ score นั่นเอง
#include <stdio.h>
int main()
{
int score = 85;
int *p = &score;
printf("score = %d\n", score);
printf("*p = %d\n", *p); // (1)!
*p = 90; // (2)!
printf("score = %d\n", score);
score = score + 5;
printf("*p = %d\n", *p); // (3)!
return 0;
}
- อ่านค่า ตามที่อยู่ใน
pไปหยิบค่าในกล่องscoreมา ได้ 85 - เขียนค่า ตามที่อยู่ไปเขียน 90 ลงในกล่อง
scoreตัวแปรpไม่เปลี่ยน ยังชี้ที่เดิม - แก้
scoreตรงๆ แล้ว*pก็เห็นค่าใหม่ทันที เพราะเป็นกล่องเดียวกัน
score = 85
*p = 85
score = 90
*p = 95
บรรทัดที่ 9 ไม่ได้เขียนชื่อ score เลย แต่ score กลายเป็น 90 นี่คือพลังของ pointer คือแก้ตัวแปรได้โดยรู้แค่ที่อยู่ ไม่ต้องรู้ชื่อ หน้า 10.2 จะใช้เรื่องนี้ให้ฟังก์ชันแก้ตัวแปรใน main
เครื่องหมาย & กับ * ทำงานกลับกัน & จากกล่องได้ที่อยู่ * จากที่อยู่ได้กล่อง
| เขียน | ความหมาย | ชนิด |
|---|---|---|
int *p; | ประกาศ p เป็น pointer ไปยัง int ดอกจันเป็นส่วนของชนิด | |
&score | ที่อยู่ของ score | int * |
p | ค่าใน p คือที่อยู่ที่เก็บไว้ | int * |
*p | กล่องที่ p ชี้ อ่านก็ได้ เขียนก็ได้ | int |
a * b | คูณ เมื่อมีค่าทั้งสองข้าง |
เปลี่ยนที่ชี้ และชี้ซ้ำกันได้
pointer ก็เป็นตัวแปร เปลี่ยนค่าได้ คือย้ายไปชี้กล่องอื่น และ pointer หลายตัวชี้กล่องเดียวกันได้
#include <stdio.h>
int main()
{
int math = 70, science = 80;
int *p = &math;
*p = *p + 5;
p = &science;
*p = *p + 5;
printf("math = %d, science = %d\n", math, science);
int *q = p;
*q = 100;
printf("math = %d, science = %d\n", math, science);
printf("%d %d\n", p == q, p == &math);
return 0;
}
math = 75, science = 85
math = 75, science = 100
1 0
- บรรทัดที่ 7 บวกคะแนนให้
mathแล้วบรรทัดที่ 8 ย้ายpไปชี้scienceบรรทัดที่ 9 ที่หน้าตาเหมือนบรรทัดที่ 7 ทุกตัวอักษรจึงไปบวกให้scienceแทน - บรรทัดที่ 11
q = pคัดลอกที่อยู่ ไม่ได้คัดลอกคะแนน ตอนนี้pกับqชี้scienceทั้งคู่ เขียนผ่านqจึงเปลี่ยนscience - เทียบ pointer ด้วย
==คือถามว่าชี้กล่องเดียวกันไหม ได้ 1 กับ 0 ตามกฎในหน้า 3.1
กลับไปดู & ใน scanf
ตอนนี้อ่าน scanf("%d", &age) ได้ครบทุกตัวแล้ว คือส่งที่อยู่ของ age ไปให้ scanf แล้ว scanf ตามที่อยู่นั้นไปเขียนตัวเลขลงกล่อง ถ้ามี pointer ที่ชี้ age อยู่แล้ว ส่ง pointer ไปได้เลยโดยไม่ต้องมี & เพราะค่าในตัว pointer คือที่อยู่อยู่แล้ว
#include <stdio.h>
int main()
{
int age;
int *p = &age;
scanf("%d", p);
printf("next year you will be %d\n", age + 1);
return 0;
}
15
next year you will be 16
คำเตือนในหน้า 1.1 ที่บอกว่า '%d' expects argument of type 'int *' จึงแปลว่า %d ใน scanf ต้องการที่อยู่ของ int แต่ได้ int ธรรมดามา ส่วนเหตุผลว่าทำไม scanf ต้องขอที่อยู่ ทั้งที่ printf ขอแค่ค่า อยู่ในหน้า 10.2
pointer มีชนิด
pointer ไปยัง double มีชนิด double * pointer ไปยัง char มีชนิด char * ชนิดบอก compiler ว่าตอนตามไปที่กล่อง ต้องอ่านกี่ byte และตีความเป็นอะไร ส่วนตัว pointer เองเก็บแค่ที่อยู่ จึงมีขนาดเท่ากันทุกชนิด ดูได้ด้วย sizeof ที่ใช้มาแล้วกับ fgets ในหน้า 6.3
#include <stdio.h>
int main()
{
printf("int %d, int * %d\n", (int)sizeof(int), (int)sizeof(int *));
printf("double %d, double * %d\n", (int)sizeof(double), (int)sizeof(double *));
printf("char %d, char * %d\n", (int)sizeof(char), (int)sizeof(char *));
return 0;
}
int 4, int * 8
double 8, double * 8
char 1, char * 8
Code::Blocks ที่ติดตั้งในบทที่ 0 สร้างโปรแกรมแบบ 64 bit ที่อยู่จึงยาว 64 bit หรือ 8 byte ตรงกับที่ %p พิมพ์ออกมา 16 หลักฐานสิบหก sizeof ให้ผลเป็นชนิด size_t แบบเดียวกับ strlen ในหน้า 6.4 จึงแปลงเป็น int ก่อนพิมพ์
NULL ไม่ได้ชี้อะไร
บางครั้ง pointer ยังไม่มีอะไรให้ชี้ ภาษา C มีค่าพิเศษชื่อ NULL แปลว่า pointer ตัวนี้ไม่ได้ชี้กล่องไหนเลย NULL ประกาศไว้ใน stdio.h ใช้ได้ทันที ตั้งค่าและเทียบได้ด้วย = กับ == เหมือนค่าทั่วไป
#include <stdio.h>
int main()
{
int score = 85;
int *p = NULL;
if (p == NULL) {
printf("p points nowhere\n");
}
p = &score;
if (p != NULL) {
printf("p points to %d\n", *p);
}
return 0;
}
p points nowhere
p points to 85
NULL ใช้เป็นคำตอบว่า "ไม่มี" หรือ "ไม่สำเร็จ" fgets ในหน้า 6.3 คืน NULL เมื่ออ่านต่อไม่ได้ ก็คือคืน pointer ที่ไม่ชี้อะไรเลย ในหน้า 10.5 คำสั่งจองหน่วยความจำก็คืน NULL เมื่อจองไม่สำเร็จเหมือนกัน ข้อสำคัญคือ ห้ามใช้ *p ตอนที่ p เป็น NULL เพราะไม่มีกล่องให้ตามไป ดูแบบที่ 5 ข้างล่าง
ลองทำให้พัง
แบบที่ 1 ลืม & ตอนให้ค่า pointer
ในโปรแกรม deref เขียนบรรทัดที่ 6 เป็น int *p = score;
deref.c:6:14: error: initialization of 'int *' from 'int' makes pointer from integer without a cast [-Wint-conversion]
6 | int *p = score;
| ^~~~~
p ต้องได้ที่อยู่ (int *) แต่ได้คะแนน 85 ซึ่งเป็น int GCC 14 ถือเป็น error ทันที เทียบกับ error ของ char grade = "A"; ในหน้า 1.4 ที่บอกว่า makes integer from pointer สองข้อความนี้คือความผิดเดียวกันคนละทิศ ตอนนั้นเอาที่อยู่ไปใส่กล่องตัวเลข ตอนนี้เอาตัวเลขไปใส่กล่องที่อยู่
แบบที่ 2 ประกาศ pointer สองตัวในบรรทัดเดียว
#include <stdio.h>
int main()
{
int math = 70, science = 80;
int *p, q;
p = &math;
q = &science;
printf("%d %d\n", *p, *q);
return 0;
}
retarget.c:8:7: error: assignment to 'int' from 'int *' makes integer from pointer without a cast [-Wint-conversion]
8 | q = &science;
| ^
retarget.c:9:27: error: invalid type argument of unary '*' (have 'int')
9 | printf("%d %d\n", *p, *q);
| ^~
ดอกจันติดอยู่กับชื่อที่ตามหลังเท่านั้น int *p, q; ได้ p เป็น int * แต่ q เป็น int ธรรมดา error ที่สองบอกว่าใช้ * กับของที่ไม่ใช่ pointer ไม่ได้ ถ้าต้องการสองตัวต้องเขียน int *p, *q; แต่อ่านง่ายกว่าถ้าประกาศ pointer บรรทัดละตัว
แบบที่ 3 ชี้ผิดชนิด
int height = 170;
double *p = &height;
height.c:6:17: error: initialization of 'double *' from incompatible pointer type 'int *' [-Wincompatible-pointer-types]
6 | double *p = &height;
| ^
ถ้ายอมให้ผ่าน *p จะอ่าน 8 byte แล้วตีความเป็นทศนิยม ทั้งที่กล่อง height มีแค่ 4 byte และเก็บจำนวนเต็ม GCC 14 จึงไม่ยอมคอมไพล์ ชนิดของ pointer ต้องตรงกับชนิดของกล่องที่ชี้เสมอ
แบบที่ 4 pointer ที่ยังไม่ได้ชี้
#include <stdio.h>
int main()
{
int *p;
*p = 90;
printf("*p = %d\n", *p);
return 0;
}
deref.c:6:8: warning: 'p' is used uninitialized [-Wuninitialized]
6 | *p = 90;
| ~~~^~~~
deref.c:5:10: note: 'p' was declared here
5 | int *p;
| ^
เป็นคำเตือนเดียวกับตัวแปรที่ยังไม่ใส่ค่าในหน้า 1.1 แต่ผลร้ายกว่า ตอนนั้นได้ค่าขยะมาพิมพ์ ตอนนี้ ที่อยู่ เป็นค่าขยะ *p = 90 จึงไปเขียนเลข 90 ลงที่ไหนก็ไม่รู้ บนเครื่องที่เขียนเอกสารนี้ รันกี่ครั้งโปรแกรมก็ปิดตัวทันที และ Code::Blocks ขึ้นว่า
Process returned -1073741819 (0xC0000005)
0xC0000005 คือรหัส access violation ตัวเดียวกับตอนใช้ช่องของอาเรย์ที่ไกลเกินขอบในหน้า 5.5 ถ้าโชคร้ายกว่านี้ ที่อยู่ขยะอาจบังเอิญชี้ไปที่ตัวแปรอื่นของโปรแกรมเอง โปรแกรมจะไม่พังแต่ค่าของตัวแปรนั้นเปลี่ยนไปเงียบๆ ซึ่งหาสาเหตุยากกว่ามาก จึงมีกฎว่า ประกาศ pointer แล้วให้ค่าทันที ถ้ายังไม่รู้จะชี้อะไร ให้ตั้งเป็น NULL
แบบที่ 5 ใช้ *p ตอนที่ p เป็น NULL
#include <stdio.h>
int main()
{
int *p = NULL;
*p = 90;
printf("*p = %d\n", *p);
return 0;
}
คอมไพล์ด้วย -Wall ไม่มีคำเตือน แต่บนเครื่องที่เขียนเอกสารนี้ รันแล้วพังด้วย Process returned -1073741819 (0xC0000005) ทุกครั้ง ต่างจากแบบที่ 4 ตรงที่แบบนี้พังแน่นอน เพราะ Windows กันที่อยู่แถว NULL ไว้ไม่ให้โปรแกรมไหนใช้ บั๊กจึงโผล่ทันทีแทนที่จะซ่อนอยู่ นี่คือเหตุผลที่ควรตั้ง pointer ที่ยังไม่ใช้เป็น NULL และตรวจ p != NULL ก่อนใช้ *p ทุกครั้งที่ไม่แน่ใจ
สรุป
- ทุกตัวแปรอยู่ที่ที่อยู่หนึ่งในหน่วยความจำ
&xให้ที่อยู่ของxพิมพ์ด้วย%pได้ค่าไม่ซ้ำกันในแต่ละครั้งที่รัน int *p = &x;ประกาศ pointer ที่เก็บที่อยู่ของxเรียกว่าpชี้x*pคือกล่องที่pชี้ อ่านและเขียนได้ เขียน*pก็คือเขียนx- pointer เปลี่ยนที่ชี้ได้ หลายตัวชี้กล่องเดียวกันได้
p == qถามว่าชี้กล่องเดียวกันไหม - ชนิดของ pointer ต้องตรงกับกล่องที่ชี้ ตัว pointer เองยาว 8 byte บนเครื่อง 64 bit
NULLคือไม่ได้ชี้อะไร pointer ที่ไม่ได้ให้ค่าหรือเป็นNULLห้ามใช้*