[0]zero-index clytie0x/zero-index

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 …F94C …F940 int * score 85 …F94C int

ลูกศรไม่ได้มีอยู่จริงในหน่วยความจำ ในกล่อง 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;
}
  1. อ่านค่า ตามที่อยู่ใน p ไปหยิบค่าในกล่อง score มา ได้ 85
  2. เขียนค่า ตามที่อยู่ไปเขียน 90 ลงในกล่อง score ตัวแปร p ไม่เปลี่ยน ยังชี้ที่เดิม
  3. แก้ score ตรงๆ แล้ว *p ก็เห็นค่าใหม่ทันที เพราะเป็นกล่องเดียวกัน
score = 85
*p = 85
score = 90
*p = 95

บรรทัดที่ 9 ไม่ได้เขียนชื่อ score เลย แต่ score กลายเป็น 90 นี่คือพลังของ pointer คือแก้ตัวแปรได้โดยรู้แค่ที่อยู่ ไม่ต้องรู้ชื่อ หน้า 10.2 จะใช้เรื่องนี้ให้ฟังก์ชันแก้ตัวแปรใน main

เครื่องหมาย & กับ * ทำงานกลับกัน & จากกล่องได้ที่อยู่ * จากที่อยู่ได้กล่อง

เขียนความหมายชนิด
int *p;ประกาศ p เป็น pointer ไปยัง int ดอกจันเป็นส่วนของชนิด
&scoreที่อยู่ของ scoreint *
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 ห้ามใช้ *