[0]zero-index clytie0x/zero-index

6.3 รับข้อความทั้งบรรทัด

%s รับได้ทีละคำ แต่ชื่อนามสกุล ที่อยู่ หรือประโยคยาวๆ มีช่องว่างอยู่ข้างใน โจทย์แบบ "นับคำในประโยค" หรือ "เข้ารหัสข้อความ" จึงต้องรับทั้งบรรทัดรวมช่องว่าง เครื่องมือที่ใช้คือ fgets ซึ่งมีนิสัยแปลกอยู่หนึ่งอย่าง และมีกับดักเมื่อใช้ต่อจาก scanf หน้านี้จะเจอทั้งสองเรื่องกับตา

%s หยุดที่ช่องว่าง

#include <stdio.h>

int main()
{
    char name[105];
    scanf("%s", name);
    printf("Hello, %s!\n", name);
    return 0;
}
Somchai Jaidee
Hello, Somchai!

นามสกุลหายไป %s หยุดอ่านเมื่อเจอช่องว่าง คำว่า Jaidee ยังค้างอยู่ในข้อมูลเข้า รอให้ใครมาอ่านต่อ

fgets รับทั้งบรรทัด

#include <stdio.h>

int main()
{
    char name[105];
    fgets(name, sizeof name, stdin); // (1)!
    printf("Hello, %s!\n", name);
    return 0;
}
  1. อ่านว่า "อ่านหนึ่งบรรทัดจาก stdin ใส่ name ใช้ไม่เกิน sizeof name ช่อง" stdin (standard input) คือข้อมูลเข้าที่ scanf อ่านอยู่ทุกวัน ส่วน sizeof name คือขนาดของอาเรย์ name เป็น byte ซึ่ง char ช่องละ 1 byte จึงได้ 105 พอดี
Somchai Jaidee
Hello, Somchai Jaidee
!

ได้ชื่อเต็มแล้ว แต่เครื่องหมาย ! ตกไปอยู่บรรทัดใหม่ นี่คือนิสัยแปลกของ fgets มันอ่านไปจน รวมตัว '\n' ด้วย แล้วค่อยใส่ '\0' ปิดท้าย ข้อความ 14 ตัวจึงใช้ 16 ช่อง

ช่อง0 ถึง 12131415
เก็บSomchai Jaide'e''\n''\0'

เรื่องขนาดอาเรย์จึงต่างจาก %s นิดหนึ่ง ถ้าโจทย์บอกว่าบรรทัดยาวไม่เกิน 100 ตัว ต้องมีที่ให้ 100 ตัว บวก '\n' บวก '\0' คือ 102 ช่องเป็นอย่างน้อย เผื่อเป็น 105 ก็ได้

ใช้ sizeof name ดีกว่าเขียน 105 ซ้ำ เพราะวันหลังเปลี่ยนขนาดอาเรย์แล้ว fgets ก็เปลี่ยนตามเอง sizeof แบบนี้ใช้ได้กับอาเรย์ที่ประกาศไว้ใน main หรือนอก main อย่างในบทนี้ ส่วนกรณีที่ใช้ไม่ได้จะเจอตอนส่งอาเรย์ให้ฟังก์ชันในบทที่ 8 และเข้าใจเหตุผลในบทที่ 10

ตัด '\n' ทิ้ง

แทบทุกครั้งที่ใช้ fgets ต้องตัด '\n' ทิ้งก่อน ไม่อย่างนั้นจะนับความยาวเกินหนึ่ง กลับคำแล้วได้ '\n' ขึ้นหน้า หรือเทียบคำแล้วไม่เท่ากัน วิธีตัดคือเดินหาแล้วเขียน '\0' ทับ

#include <stdio.h>

int main()
{
    char name[105];
    fgets(name, sizeof name, stdin);
    for (int i = 0; name[i] != '\0'; i++) {
        if (name[i] == '\n' || name[i] == '\r') {
            name[i] = '\0';
        }
    }
    printf("Hello, %s!\n", name);
    return 0;
}
Somchai Jaidee
Hello, Somchai Jaidee!

ตัด '\r' ด้วยเผื่อไว้ ไฟล์ข้อมูลเข้าที่สร้างบน Windows จบบรรทัดด้วยสองตัวคือ '\r' ตามด้วย '\n' โปรแกรมบน Windows แปลงให้เหลือ '\n' ตัวเดียวโดยอัตโนมัติ แต่ grader ส่วนใหญ่รันบน Linux ซึ่งไม่แปลง ถ้าไม่ตัด '\r' ก็จะได้ตัวเกินมาหนึ่งตัวแบบที่มองไม่เห็น

อ่านหลายบรรทัดจนหมด

fgets คืนค่าพิเศษชื่อ NULL เมื่ออ่านต่อไม่ได้แล้ว ใช้แบบเดียวกับ while (scanf(...) == 1) ในบทที่ 4 ได้ ส่วน NULL หมายถึงอะไรจริงๆ จะเรียนในบทที่ 10

#include <stdio.h>

int main()
{
    char line[105];
    int count = 0;
    while (fgets(line, sizeof line, stdin) != NULL) {
        count++;
        printf("%d %s", count, line);
    }
    return 0;
}
rice 2 kg
eggs 10
fish sauce
1 rice 2 kg
2 eggs 10
3 fish sauce

คราวนี้ printf ไม่มี \n เพราะ line มี '\n' ติดมาแล้ว เป็นกรณีเดียวที่นิสัยของ fgets ช่วยเรา

กับดัก scanf ตามด้วย fgets

โจทย์จำนวนมากให้ตัวเลขบรรทัดแรก แล้วตามด้วยข้อความหนึ่งบรรทัด เช่น เลขห้อง แล้วตามด้วยชื่อเต็มของคนที่พักห้องนั้น วิธีที่ทุกคนเขียนครั้งแรกคือ

#include <stdio.h>

int main()
{
    int room;
    char name[105];
    scanf("%d", &room);
    fgets(name, sizeof name, stdin);
    printf("room %d\n", room);
    printf("name [%s]\n", name);
    return 0;
}
5
Somchai Jaidee
room 5
name [
]

วงเล็บเหลี่ยมใส่ไว้ให้เห็นขอบของข้อความ ชื่อว่างเปล่า มีแค่ '\n' ตัวเดียว ถ้ารันใน Code::Blocks เองจะยิ่งงง เพราะโปรแกรมไม่รอให้พิมพ์ชื่อเลยด้วยซ้ำ สาเหตุอยู่ที่ข้อมูลเข้าคือตัวอักษรเรียงต่อกันหนึ่งสาย

5 \n S o m c h a i ... scanf %d fgets stops never read
  1. scanf("%d") อ่าน 5 แล้วหยุดทันทีที่เจอตัวที่ไม่ใช่ตัวเลข คือ '\n' และ ไม่อ่านตัวนั้น ทิ้งไว้ในสาย
  2. fgets อ่านจนถึง '\n' ตัวแรกที่เจอ ซึ่งก็คือตัวที่ค้างอยู่ จึงได้บรรทัดว่างแล้วจบ ชื่อจริงไม่ถูกอ่านเลย

สองตัวนี้ทำงานถูกตามที่ออกแบบไว้ทั้งคู่ ปัญหาคือ '\n' ท้ายบรรทัดแรกไม่มีใครเก็บ วิธีแก้คือเก็บเองก่อนเรียก fgets ด้วย getchar() ซึ่งอ่านตัวอักษรหนึ่งตัวจากข้อมูลเข้าแล้วคืนรหัสของมันมา เหมือน scanf("%c") แบบสั้น

#include <stdio.h>

int main()
{
    int room;
    char name[105];
    scanf("%d", &room);
    while (getchar() != '\n') { // (1)!
    }
    fgets(name, sizeof name, stdin);
    for (int i = 0; name[i] != '\0'; i++) {
        if (name[i] == '\n' || name[i] == '\r') {
            name[i] = '\0';
        }
    }
    printf("room %d\n", room);
    printf("name [%s]\n", name);
    return 0;
}
  1. อ่านทิ้งทีละตัวจนกว่าจะได้ '\n' ซึ่งก็อ่านทิ้งไปด้วย ใช้ลูปแทน getchar() ครั้งเดียว เพราะหลังเลข 5 อาจมีช่องว่างหรือ '\r' ค้างอยู่ก่อนถึง '\n' ลูปนี้ไม่มีคำสั่งในปีกกา งานทั้งหมดเกิดในเงื่อนไข
5
Somchai Jaidee
room 5
name [Somchai Jaidee]

จำง่ายๆ

หลัง scanf ที่จบบรรทัดแล้ว ถ้าคำสั่งถัดไปคือ fgets ต้องอ่านทิ้งให้ถึง '\n' ก่อนเสมอ ส่วน scanf ตามด้วย scanf("%d") หรือ scanf("%s") ไม่มีปัญหานี้ เพราะสองแบบนั้นข้ามช่องว่างและ '\n' ข้างหน้าให้เอง เหมือน " %c" ที่เรียนในบทที่ 2

ลองทำให้พัง

แบบที่ 1 อาเรย์เล็กกว่าบรรทัด

#include <stdio.h>

int main()
{
    char name[10];
    fgets(name, sizeof name, stdin);
    printf("[%s]\n", name);
    fgets(name, sizeof name, stdin);
    printf("[%s]\n", name);
    return 0;
}
Somchai Jaidee
[Somchai J]
[aidee
]

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

แบบที่ 2 ใช้ gets ตามเว็บเก่า

เว็บและหนังสือเก่าหลายเล่มสอนให้รับทั้งบรรทัดด้วย gets(name); ลองเปลี่ยนบรรทัด fgets ในโปรแกรม greet เป็นแบบนั้น

greet.c:6:5: warning: call to 'gets' declared with attribute warning: Using gets() is always unsafe - use fgets() instead [-Wattribute-warning]
    6 |     gets(name);
      |     ^~~~~~~~~~

compiler บอกตรงๆ ว่า gets ไม่ปลอดภัยเสมอ เพราะ gets ไม่รู้ขนาดของอาเรย์ บรรทัดยาวเท่าไรก็เขียนลงไปหมด จนทับหน่วยความจำของตัวแปรอื่น มาตรฐานภาษา C ถอด gets ออกไปแล้วตั้งแต่ C11 ใช้ fgets แล้วตัด '\n' เองแทน

สรุป

  • fgets(line, sizeof line, stdin) รับทั้งบรรทัดรวมช่องว่าง และรวม '\n' ท้ายบรรทัดด้วย
  • อาเรย์ต้องมีขนาดอย่างน้อยความยาวบรรทัดบวกสอง สำหรับ '\n' กับ '\0'
  • ตัด '\n' และ '\r' ทิ้งด้วยการเขียน '\0' ทับก่อนใช้งาน
  • while (fgets(...) != NULL) อ่านทุกบรรทัดจนหมด
  • หลัง scanf ถ้าจะใช้ fgets ต้องอ่านทิ้งถึง '\n' ก่อน ด้วย while (getchar() != '\n')
  • ห้ามใช้ gets