[0]zero-index clytie0x/zero-index

10.4 สตริงกับ char *

สตริงในบทที่ 6 คืออาเรย์ของ char ที่ปิดท้ายด้วย '\0' พอรู้ว่าชื่ออาเรย์คือที่อยู่ของช่องแรก ก็จะเห็นว่าฟังก์ชันสตริงทุกตัว ตั้งแต่ printf("%s") จนถึง strlen ทำงานกับ char * คือที่อยู่ของตัวอักษรตัวหนึ่ง แล้วเดินไปเรื่อยๆ จนเจอ '\0' หน้านี้ใช้ความรู้นี้ตัดสตริงเป็นท่อน ลบตัวอักษรออกจากสตริงโดยไม่ใช้อาเรย์ที่สอง และอธิบายคำเตือนกับผลแปลกๆ ของสตริงที่ค้างไว้ตั้งแต่บทที่ 2 และบทที่ 6

%s รับที่อยู่ แล้วพิมพ์จนเจอ \0

printf("%s", word) ไม่ได้รับทั้งอาเรย์ มันได้แค่ที่อยู่ของ word[0] ซึ่งมีชนิด char * แล้วพิมพ์ทีละตัวไปจนเจอ '\0' ถ้าส่งที่อยู่ของช่องอื่นไป มันก็เริ่มพิมพ์จากช่องนั้น

#include <stdio.h>

int main()
{
    char word[] = "programming";
    printf("%s\n", word);
    printf("%s\n", word + 3);
    printf("%c\n", *(word + 3));
    for (char *p = word + 7; *p != '\0'; p++) {
        printf("%s\n", p);
    }
    return 0;
}
programming
gramming
g
ming
ing
ng
g
  • บรรทัดที่ 7 word + 3 คือที่อยู่ของ word[3] ซึ่งเก็บ g %s จึงพิมพ์ตั้งแต่ตัวนั้นไปจนจบ
  • บรรทัดที่ 8 *(word + 3) คือตัวอักษรในช่องนั้นหนึ่งตัว ชนิด char จึงพิมพ์ด้วย %c ความต่างระหว่าง char * กับ char คือความต่างระหว่าง "ข้อความที่เริ่มตรงนี้" กับ "ตัวอักษรตัวนี้"
  • ลูปบรรทัดที่ 9 เลื่อน p ทีละตัว ทุกครั้งที่พิมพ์ %s จึงได้ส่วนท้ายที่สั้นลงทีละตัว ลูปหยุดเมื่อ *p เป็น '\0' แบบเดียวกับที่ printf ทำข้างใน

scanf("%s", word) ก็เหมือนกัน มันต้องการที่อยู่ของช่องแรกที่จะเริ่มเขียน ชื่อ word ให้ที่อยู่นั้นอยู่แล้ว จึงไม่ต้องมี & ตามที่หน้า 2.3 บอกไว้

&word คือที่อยู่ของทั้งอาเรย์

หน้า 2.3 และหน้า 6.2 ลองใส่ & หน้าชื่ออาเรย์ใน scanf แล้วได้คำเตือนว่า '%s' expects argument of type 'char *', but argument 2 has type 'char (*)[105]' ทั้งที่โปรแกรมยังทำงานถูก ลองพิมพ์ที่อยู่ดู

#include <stdio.h>

int main()
{
    char word[105] = "pointer";
    printf("word      = %p\n", word);
    printf("&word     = %p\n", &word);
    printf("word + 1  = %p\n", word + 1);
    printf("&word + 1 = %p\n", &word + 1);
    return 0;
}
word      = 00000081D6DFFBC0
&word     = 00000081D6DFFBC0
word + 1  = 00000081D6DFFBC1
&word + 1 = 00000081D6DFFC29

word กับ &word เป็นเลขเดียวกัน เพราะอาเรย์เริ่มที่ช่องแรก แต่ชนิดต่างกัน word คือที่อยู่ของ char หนึ่งตัว (char *) ส่วน &word คือที่อยู่ของทั้งอาเรย์ 105 ช่อง ชนิดของมันเขียนว่า char (*)[105] อ่านว่า pointer ไปยังอาเรย์ของ char 105 ช่อง ความต่างโผล่ตอนบวก 1 word + 1 ขยับ 1 byte ไปตัวอักษรถัดไป แต่ &word + 1 ขยับไปทั้งก้อน จาก BC0 เป็น C29 ซึ่งห่างกัน 69 ในฐานสิบหก คือ 105 พอดี

scanf เขียนตัวอักษรทีละตัวเริ่มจากที่อยู่ที่ได้ เลขที่อยู่ตรงกันจึงบังเอิญทำงานถูก แต่ชนิดผิด compiler จึงเตือน และถ้าเอาไปบวกเลขแบบในตัวอย่างข้างบน ความต่างของชนิดจะได้ที่อยู่ผิดไปทันที เขียนชื่ออาเรย์เปล่าๆ ทุกครั้ง

เดินบนสตริงด้วย pointer

ฟังก์ชันที่รับสตริงจึงรับเป็น char * ได้ แล้วเดินด้วย pointer จนเจอ '\0' ไม่ต้องรับความยาวเพราะสตริงบอกจุดจบเอง ตัวอย่างนี้เขียน strlen เอง และนับตัวพิมพ์ใหญ่

#include <stdio.h>

int length(char *s)
{
    char *p = s;
    while (*p != '\0') {
        p++;
    }
    return (int)(p - s); // (1)!
}

int count_upper(char *s)
{
    int count = 0;
    for (char *p = s; *p != '\0'; p++) {
        if (*p >= 'A' && *p <= 'Z') {
            count++;
        }
    }
    return count;
}

int main()
{
    char name[105];
    scanf("%s", name);
    printf("length %d, upper %d\n", length(name), count_upper(name));
    return 0;
}
  1. p หยุดที่ช่องของ '\0' ระยะห่างจากช่องแรกคือจำนวนตัวอักษรก่อน '\0' พอดี ใช้การลบ pointer จากหน้า 10.3
SongkhlaNakarin
length 15, upper 2

เทียบกับหน้า 8.3 ที่เขียน char text[] แล้วใช้ text[i] สองแบบทำงานเหมือนกัน แบบ index อ่านง่ายเมื่อต้องรู้ตำแหน่ง แบบ pointer สะดวกเมื่อต้องจำ "ตรงนี้" ไว้ใช้ต่อ อย่างในหัวข้อถัดไป

ฟังก์ชันที่คืนที่อยู่ในสตริง

อีเมลแบ่งเป็นชื่อผู้ใช้กับโดเมน คั่นด้วย @ ฟังก์ชัน find_char คืนที่อยู่ของตัวที่หาเจอตัวแรก หรือคืน NULL ถ้าไม่มี เมื่อได้ที่อยู่ของ @ แล้ว เขียน '\0' ทับตรงนั้น สตริงเดียวก็กลายเป็นสองท่อนทันที

#include <stdio.h>

char *find_char(char *s, char c) // (1)!
{
    for (char *p = s; *p != '\0'; p++) {
        if (*p == c) {
            return p;
        }
    }
    return NULL; // (2)!
}

int main()
{
    int n;
    scanf("%d", &n);
    for (int i = 0; i < n; i++) {
        char email[105];
        scanf("%s", email);
        char *at = find_char(email, '@');
        if (at == NULL) {
            printf("%s is not an email\n", email);
        } else {
            *at = '\0'; // (3)!
            printf("user %s, domain %s\n", email, at + 1); // (4)!
        }
    }
    return 0;
}
  1. ชนิดที่คืนคือ char * ที่อยู่ที่คืนเป็นของช่องในอาเรย์ email ของ main ซึ่งยังอยู่หลังฟังก์ชันจบ จึงคืนได้ ต่างจากแบบที่ 4 ของหน้า 10.2
  2. เดินจนจบแล้วไม่เจอ คืน NULL ตามความหมายในหน้า 10.1
  3. แก้ช่องที่ at ชี้ จาก '@' เป็น '\0' สตริงที่เริ่มจาก email จึงจบตรงนี้
  4. โดเมนเริ่มที่ตัวถัดจาก @ และจบที่ '\0' เดิมท้ายอีเมล
3
ploy@psu.ac.th
somchai.k@posn.or.th
hello
user ploy, domain psu.ac.th
user somchai.k, domain posn.or.th
hello is not an email
email at + 1 at p l o y \0 p s u . a c . t h \0 was @

ไม่มีการคัดลอกตัวอักษรเลยสักตัว สองท่อนยังอยู่ในอาเรย์เดิม แค่มี pointer สองตัวชี้จุดเริ่มของแต่ละท่อน และมี '\0' ปิดท้ายแต่ละท่อน วิธีนี้ตัดสตริงยาวเป็นท่อนได้เร็วมาก แต่สตริงเดิมจะเสียไป ถ้ายังต้องใช้ทั้งอีเมลอีก ให้พิมพ์หรือคัดลอกไว้ก่อนตัด

pointer อ่านกับเขียนบนสตริงเดียวกัน

เบอร์โทร 081-234-5678 ต้องการเอาขีดออกให้เหลือแต่ตัวเลข วิธีหนึ่งคือคัดลอกตัวที่ไม่ใช่ขีดไปอีกอาเรย์ แต่ทำในอาเรย์เดิมได้ด้วย pointer สองตัว read เดินอ่านทุกตัว write ชี้ช่องถัดไปที่จะเขียน ตัวที่จะเก็บไว้ถูกคัดลอกจาก read ไป write

#include <stdio.h>

void remove_char(char *s, char c)
{
    char *write = s;
    for (char *read = s; *read != '\0'; read++) {
        if (*read != c) {
            *write = *read; // (1)!
            write++;
        }
    }
    *write = '\0'; // (2)!
}

int main()
{
    char phone[105];
    scanf("%s", phone);
    remove_char(phone, '-');
    printf("%s\n", phone);
    return 0;
}
  1. write ไม่มีทางแซง read เพราะขยับได้อย่างมากเท่ากับ read ตัวที่เขียนทับจึงเป็นตัวที่อ่านไปแล้วเสมอ ไม่ทำให้ข้อมูลที่ยังไม่ได้อ่านเสีย
  2. สตริงใหม่สั้นกว่าเดิม ต้องปิดท้ายตรงที่ write หยุด ไม่งั้น '\0' ยังอยู่ที่เดิมท้ายสตริงยาว ดูแบบที่ 2 ในส่วนลองทำให้พัง
081-234-5678
0812345678

เดินรอบเดียว ไม่ต้องเลื่อนตัวอักษรทั้งแถวทุกครั้งที่ลบแบบหน้า 5.5 ถ้าลบด้วยการเลื่อนทั้งแถว สตริงยาว n ที่ต้องลบเกือบทุกตัวจะเลื่อนรวมราว n2 / 2 ครั้ง แบบสองตัวชี้ใช้แค่ n ครั้ง เปลี่ยนเงื่อนไขในบรรทัดที่ 7 ก็ได้งานอื่น เช่น ลบสระ เก็บเฉพาะตัวเลข หรือยุบช่องว่างที่ติดกัน

ข้อความในเครื่องหมายคำพูด

ข้อความอย่าง "Monday" ในโค้ดก็เป็นอาเรย์ของ char ที่ compiler เตรียมไว้ให้ในหน่วยความจำส่วนที่อ่านได้อย่างเดียว เมื่อใช้เป็นค่าจะได้ที่อยู่ของตัวแรก เก็บที่อยู่นี้ไว้ใน const char * ได้ คำว่า const หน้าชนิดคือสัญญาว่าจะไม่แก้ตัวอักษรผ่าน pointer นี้ ถ้าเผลอแก้ compiler จะฟ้อง อาเรย์ของ const char * จึงเป็นตารางข้อความที่สะดวกมาก

#include <stdio.h>

int main()
{
    const char *days[7] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}; // (1)!
    int today, after;
    scanf("%d %d", &today, &after);
    printf("%s\n", days[(today + after) % 7]);
    return 0;
}
  1. อาเรย์เจ็ดช่อง แต่ละช่องเก็บ ที่อยู่ ของข้อความหนึ่งข้อความ ไม่ได้เก็บตัวอักษรเอง ข้อความจึงยาวไม่เท่ากันได้ ต่างจากอาเรย์สองมิติของ char ในบทที่ 7 ที่ทุกแถวยาวเท่ากัน
1 10
Thursday

วันนี้วันจันทร์ (วันที่ 1 เมื่อนับวันอาทิตย์เป็น 0) อีก 10 วันสอบ (1 + 10) % 7 = 4 คือวันพฤหัสบดี

ต้องแยกให้ออกระหว่างสองบรรทัดนี้ ซึ่งหน้าตาเกือบเหมือนกัน

เขียนได้อะไรแก้ตัวอักษรได้ไหม
char name[] = "Mali";อาเรย์ 5 ช่องของเราเอง ข้อความถูกคัดลอกมาใส่ได้ name[0] = 'B'; ได้ Bali
const char *name = "Mali";pointer ตัวเดียว เก็บที่อยู่ของข้อความในส่วนที่อ่านได้อย่างเดียวไม่ได้ ดูแบบที่ 1 ในส่วนลองทำให้พัง

เรื่องนี้อธิบายผลแปลกของ answer == "yes" ในหน้า 6.4 ได้ด้วย ทั้งสองข้างของ == ถูกแปลงเป็นที่อยู่ == จึงเทียบที่อยู่ ไม่ได้เทียบตัวอักษร ลองพิมพ์ที่อยู่ของทั้งสองดู

#include <stdio.h>

int main()
{
    char answer[10] = "yes";
    printf("answer is at %p\n", answer);
    printf("\"yes\" is at %p\n", "yes");
    return 0;
}
answer is at 000000C98C3FF796
"yes" is at 00007FF7F8755061

บนเครื่องที่เขียนเอกสารนี้ อาเรย์ answer อยู่ย่านเดียวกับตัวแปรอื่นใน main ส่วน "yes" อยู่ห่างออกไปมากในส่วนที่อ่านได้อย่างเดียว ตัวอักษรในสองที่นั้นเหมือนกันทุกตัว แต่ที่อยู่ไม่มีทางเท่ากัน == จึงได้เท็จเสมอ เทียบสตริงต้องใช้ strcmp ซึ่งเดินเทียบทีละตัวอักษรด้วย pointer แบบในหน้านี้

ลองทำให้พัง

แบบที่ 1 แก้ข้อความในเครื่องหมายคำพูด

ลืมใส่ const แล้วแก้ตัวอักษรผ่าน pointer

#include <stdio.h>

int main()
{
    char *name = "Mali";
    name[0] = 'B';
    printf("%s\n", name);
    return 0;
}

คอมไพล์ด้วย -Wall ไม่มีคำเตือน เพราะไม่มี const ให้ compiler ตรวจ แต่รันแล้วไม่พิมพ์อะไร บนเครื่องที่เขียนเอกสารนี้ได้

Process returned -1073741819 (0xC0000005)

name[0] = 'B' พยายามเขียนลงหน่วยความจำส่วนที่อ่านได้อย่างเดียว Windows จึงปิดโปรแกรม ถ้าเขียน const char *name = "Mali"; ตั้งแต่แรก บรรทัดที่ 6 จะกลายเป็น error assignment of read-only location '*name' ตอนคอมไพล์ ซึ่งดีกว่าพังตอนรันมาก ถ้าต้องการข้อความที่แก้ได้ ให้ใช้อาเรย์ char name[] = "Mali";

แบบที่ 2 ลืมปิดท้ายด้วย \0

ในโปรแกรม phone ลบบรรทัด *write = '\0'; ออก

#include <stdio.h>

void remove_char(char *s, char c)
{
    char *write = s;
    for (char *read = s; *read != '\0'; read++) {
        if (*read != c) {
            *write = *read;
            write++;
        }
    }
}

int main()
{
    char phone[105];
    scanf("%s", phone);
    remove_char(phone, '-');
    printf("%s\n", phone);
    return 0;
}
081-234-5678
081234567878

สิบตัวแรกถูกแล้ว แต่มี 78 เกินมาท้าย สองช่องนั้นคือตัวอักษรเดิมของสตริงยาว ที่ write ไปไม่ถึง และ '\0' ยังอยู่ที่ตำแหน่งเดิมคือช่องที่ 12 printf จึงพิมพ์ไปถึงตรงนั้น เขียนผ่าน pointer เมื่อไร ต้องถามตัวเองว่าสตริงจบที่ไหน

แบบที่ 3 ใช้ผลที่เป็น NULL

ในโปรแกรม email ตัด if (at == NULL) ออก แล้วตัดอีเมลทันที

#include <stdio.h>

char *find_char(char *s, char c)
{
    for (char *p = s; *p != '\0'; p++) {
        if (*p == c) {
            return p;
        }
    }
    return NULL;
}

int main()
{
    char email[105];
    scanf("%s", email);
    char *at = find_char(email, '@');
    *at = '\0';
    printf("user %s, domain %s\n", email, at + 1);
    return 0;
}

ป้อน ploy@psu.ac.th ทำงานถูก แต่ป้อน somchai.school.ac.th ที่พิมพ์ @ ผิดเป็นจุด โปรแกรมไม่พิมพ์อะไรเลย บนเครื่องที่เขียนเอกสารนี้ได้ Process returned -1073741819 (0xC0000005) เพราะ at เป็น NULL แล้วบรรทัดที่ 18 เขียนผ่านมัน แบบเดียวกับแบบที่ 5 ของหน้า 10.1 ฟังก์ชันที่อาจคืน NULL ต้องตรวจค่าที่คืนก่อนใช้ทุกครั้ง ชุดทดสอบของโจทย์มักมีกรณี "ไม่เจอ" ซ่อนอยู่เสมอ

แบบที่ 4 ส่งตัวอักษรตัวเดียวให้ %s

#include <stdio.h>

int main()
{
    char word[105] = "pointer";
    printf("%s\n", word[0]);
    return 0;
}
first.c:6:14: warning: format '%s' expects argument of type 'char *', but argument 2 has type 'int' [-Wformat=]
    6 |     printf("%s\n", word[0]);
      |             ~^     ~~~~~~~
      |              |         |
      |              char *    int
      |             %d

word[0] คือตัว p ซึ่งมีรหัส 112 ไม่ใช่ที่อยู่ (compiler บอกว่าเป็น int เพราะ char ถูกแปลงเป็น int ตอนส่งให้ printf) แต่ %s จะเอา 112 ไปใช้เป็นที่อยู่แล้วตามไปอ่าน บนเครื่องที่เขียนเอกสารนี้โปรแกรมพังด้วย 0xC0000005 ทุกครั้ง อยากพิมพ์ตัวอักษรตัวเดียวใช้ %c อยากพิมพ์ข้อความตั้งแต่ตัวนั้นใช้ %s กับ word + 0 หรือ &word[0]

สรุป

  • ชื่ออาเรย์ char ที่ใช้เป็นค่าคือ char * ที่อยู่ของตัวแรก %s รับที่อยู่แล้วพิมพ์ไปจนเจอ '\0' ส่ง word + k ก็ได้ข้อความตั้งแต่ตัวที่ k
  • &word มีเลขที่อยู่เท่ากับ word แต่ชนิดเป็น char (*)[105] ใน scanf("%s") ใช้ชื่ออาเรย์เปล่าๆ
  • เดินสตริงด้วย for (char *p = s; *p != '\0'; p++) และความยาวคือ p - s ตอนเจอ '\0'
  • ฟังก์ชันคืน char * ชี้เข้าไปในสตริงได้ และคืน NULL เมื่อไม่เจอ คนเรียกต้องตรวจ NULL ก่อนใช้
  • เขียน '\0' ทับตรงไหน สตริงก็จบตรงนั้น ใช้ตัดสตริงเป็นท่อน และต้องปิดท้ายทุกครั้งที่เขียนสตริงใหม่ด้วย pointer
  • ข้อความในเครื่องหมายคำพูดแก้ไม่ได้ เก็บด้วย const char * และ == กับสตริงเทียบที่อยู่ ไม่ได้เทียบตัวอักษร