Löydöksiä, rakennuslokeja ja muita huomioita.
C-kielen merkkijonot hämmentävät lähes jokaista. Syy on yksinkertainen: C:ssä merkkijono ei ole oma tietotyyppinsä. Tavuja tulkitaan merkkijonona. Kirjoituksessa nuuhkimme merkkijonoja. Käymme läpi miten merkkijonoja muodostetaan, tarkastetaan, muutetaan ja tuhotaan. Matkalla on myös useita ansoja ja ongelmakohtia.
C-kielen merkkijonot hämmentävät lähes jokaista. Syy on yksinkertainen: C:ssä merkkijono ei ole oma tietotyyppinsä. Tavuja tulkitaan merkkijonona. Kirjoituksessa nuuhkimme merkkijonoja. Käymme läpi miten merkkijonoja muodostetaan, tarkastetaan, muutetaan ja tuhotaan. Matkalla on myös useita ansoja ja ongelmakohtia.
C:ssä merkkijono on tavutaulukko, jonka lopussa on tavu arvoltaan nolla. Ei mitään muuta. Ei pituustietoa, ei metadataa — pelkkä sopimus siitä, että lukija pysähtyy ensimmäiseen nollatavuun.
char paikka[] = "Koti";
Tämä varaa viisi tavua: 'K' 'o' 't' 'i' '\0'. Se viimeinen
näkymätön tavu on koko systeemin selkäranka.
Kaikki <string.h>-kirjaston funktiot luottavat siihen,
että nollatavu löytyy jostain. Jos nollatavu puuttuu, funktio ei tiedä milloin
lopettaa.
#include <stdio.h>
int main(void) {
char nimi[5] = {'E', 'e', 'r', 'o', '\0'};
printf("%s\n", nimi);
return 0;
}
Jos viimeinen '\0' jää pois, printf jatkaa
lukemista muistista siitä eteenpäin, kunnes osuu sattumalta nollaan.
Tuloksena on kuran lentämistä tulosteeseen — tai kaatuminen. Kääntäjän
mielestä kaikki on kunnossa. Virhe tulee ilmi vasta ajonaikana,
suorittamalla binääriä. Usein hankalimmalla mahdollisella hetkellä.
Tämä on yleisin sekaannuksen lähde koko kielessä. Nämä kaksi esimerkkiä näyttävät samalta.
char a[] = "kissa";
char *p = "kissa";
a on taulukko, joka omistaa oman kopionsa merkeistä. Kopio on
muokattavissa. p puolestaan on osoitin, joka osoittaa
merkkijonovakioon. Itse osoitinmuuttujan saa ohjata osoittamaan minne
tahansa — mutta sitä mihin se osoittaa, eli merkkijonovakiota itseään,
ei saa muokata.
a[0] = 'K'; // OK, taulukko on muokattavissa
// p[0] = 'K'; // Määrittelemätön käytös – älä tee näin
Ero näkyy myös sizeof:ssa:
printf("%zu\n", sizeof(a)); // 6 (koko taulukko)
printf("%zu\n", sizeof(p)); // 8 (osoittimen koko 64-bittisellä alustalla)
Taulukko tietää käännösaikaisen kokonsa. Osoitin sisältää vain muistiosoitteensa.
Lainausmerkeissä kirjoitettu teksti eli merkkijonovakiot sijoitetaan
staattiseen, käytännössä vain luku -muistiin. Vanha koodi käyttää sille
usein tyyppiä char *, mutta oikeampi valinta on
const char *, koska se kertoo aikomuksen suoraan tyypissä:
const char *tervehdys = "moi";
C ei salli taulukon sisällön kopiointia sijoitusoperaattorilla esittelyn jälkeen:
char a[20] = "kissa";
char b[20];
// b = a; // ei käänny
Tähän tarvitaan <string.h>:n funktioita.
| Tehtävä | Funktio | Muistiohuomautus/th> |
|---|---|---|
| Pituus | strlen | Ei laske mukaan lopetusmerkkiä |
| Kopiointi | strcpy | Kohteen on oltava riittävän suuri |
| Vertailu | strcmp | Palauttaa 0, kun merkkijonot ovat samat |
| Yhdistely | strcat | Kohteessa on jo oltava kelvollinen merkkijono |
Erityisesti merkkijonojen vertailu on hankala kohta. Tulee tahattomasti tehtyä ongelmia itselleen:
char eka[] = "kissa";
char toka[] = "kissa";
if (eka == toka) {
// false – vertailee osoitteita, ei sisältöä
}
if (strcmp(eka, toka) == 0) {
// true – vertailee tavu tavulta
}
Suomenkielisessä sisällössä tämä on käytännössä yleisempi ansa
kuin moni edellä mainituista. C:n merkkijonofunktiot laskevat tavuja,
eivät merkkejä. UTF-8:ssa ääkköset vievät kaksi tavua per merkki. Funktio
strlen palauttaa arvon, joka voi yllättää:
#include <stdio.h>
#include <string.h>
int main(void) {
const char *nimi = "Äiti";
printf("%zu\n", strlen(nimi)); // 5, ei 4
return 0;
}
Sama ongelma näkyy räikeämmin, jos merkkijonoa katkaistaan tavumäärän perusteella. Katkaisukohta voi osua keskelle monitavuista merkkiä, jolloin tulos ei ole enää kelvollista UTF-8:aa: teksti tulostuu rikkinäisenä tai jää kokonaan piippuun.
char nimi[] = "Äiti";
char lyhyt[3];
strncpy(lyhyt, nimi, 2); // katkaisee 'Ä':n keskeltä
lyhyt[2] = '\0';
Vakio-C:n kirjasto ei tunne UTF-8:aa käsitteenä lainkaan. Kirjasto näkee vain
tavuja. Jos merkkien (ei tavujen) määrä on oleellinen, esimerkiksi
syötteen pituuden rajoittamisessa käyttöliittymässä, tavumäärän
laskeminen ei riitä. On käveltävä merkkijono läpi UTF-8:n
tavusekvenssit tunnistaen tai käytettävä kirjastoa kuten utf8proc.
Yksinkertainen nyrkkisääntö: tavu, jonka ylimmät bitit ovat 10
(eli arvo väliltä 0x80–0xBF), on aina monitavuisen merkin jatko-osa, ei
oma merkkinsä.
Vanha gets-funktio poistettiin kielestä kokonaan. Funktiolla ei ollut
keinoa rajoittaa luettavan datan määrää. Nykyään käytämme fgets-funktiota.
#include <stdio.h>
int main(void) {
char nimi[32];
printf("Anna nimesi: ");
if (fgets(nimi, sizeof(nimi), stdin)) {
printf("Kirjoitit: %s", nimi);
}
return 0;
}
fgets lukee korkeintaan sizeof(nimi) - 1
merkkiä ja lisää lopetusmerkin itse. Se jättää kuitenkin usein
rivinvaihdon mukaan. Herrasmies siivoaa ylimääräisen rivinvaihdon.
#include <string.h>
nimi[strcspn(nimi, "\n")] = '\0';
Tämä strcspn-temppu kannattaa opetella ulkoa. Temppu toistuu
lähes joka kerta, kun luetaan rivi käyttäjän syötettä.
Yksi liian pieni puskuri. Viiden näkyvän merkin tallentaminen vaatii kuuden tavun tilan.
char ok[6] = "kissa"; // mahtuu: k i s s a \0
// char liian_pieni[5] = "kissa"; // ei mahdu
Alustamattoman muistin käyttö merkkijonona.
char puskuri[20];
printf("%s\n", puskuri); // virhe: ei sisällä kelvollista merkkijonoa
strncpy ei olekaan se turvallinen strcpy:n korvike, jonka moni sen luulee olevan.
Se voi jättää kohteen päättämättä, jos lähde on liian pitkä:
char dst[5];
strncpy(dst, "aakkoset", sizeof(dst));
// dst ei välttämättä pääty nollatavuun
Jos käytät sitä, päätä merkkijono lisäämällä nollatavu itse:
strncpy(dst, "aakkoset", sizeof(dst) - 1);
dst[sizeof(dst) - 1] = '\0';
%s jollekin, joka ei ole merkkijono.
char merkki = 'A';
printf("%s\n", &merkki); // virhe: ei osoita kelvolliseen merkkijonoon
Lukemalla koodia virheet jäävät huomaamatta. Moni toimii näennäisesti
oikein, kunhan muistin sisältö sattuu olemaan sopiva.
AddressSanitizer (gcc -fsanitize=address tai
clang -fsanitize=address) löytää puskurin ylivuodot ja
vapautetun muistin käytön ajonaikaisesti. Valgrind
paljastaa alustamattoman muistin lukemisen. Molemmat kannattaa ottaa
käyttöön kehitystyön ajaksi rutiinilla, ei vasta sitten kun jokin
räjähtää käsiin. Käyttöönotto paljastaa yllättävän usein bugeja, joita
ei tiennyt edes omistavansa.
C23 ei muuta merkkijonojen perusideaa. Nollapäätetty tavutaulukko on
yhä sama sopimus kuin ennenkin. C23 tuo silti mukanaan muutaman asian,
jotka kannattaa tuntea, vaikka C-standardikomitea niistä vuosikymmenen
vääntikin. nullptr korvaa NULL:in
tyyppiturvallisemmalla vaihtoehdolla, vaikka merkkijono-osoittimien
kanssa NULL on yhä se idiomi, jota näkee eniten olemassa
olevassa koodissa:
char *p = nullptr; // C23
char *q = NULL; // yhä yleisin tapa, toimii edelleen
Toinen käytännön parannus on [[nodiscard]]-attribuutti: sillä
voi merkitä oman funktion niin, että kääntäjä varoittaa, jos paluuarvoa ei
käytetä — hyödyllinen esimerkiksi silloin, kun funktio palauttaa
osoittimen puskuriin, jonka onnistuminen pitää aina tarkistaa:
[[nodiscard]] char *turvallinen_kopio(const char *lahde);
Kumpikaan ei poista tässä kirjoituksessa käsiteltyjä perusongelmia. Puskurin koon ja nollapäätöksen pitää olla yhä itse hallinnassa. Molemmat auttavat kääntäjää huomauttamaan virheistä aiemmin.
#define NIMI_KOKO 32fgets-funktiota rivin lukemiseen, jätä pois gets-funktiosnprintf-funktiota merkkijonojen rakenteluun käsipelin sijaanconst char * -tyypillästrcmp-funktiolla. Osoittimien vertailu ==-operaattorilla on synti, josta rangaistaan säälimättä.void tervehdi(char *dst, size_t koko, const char *nimi) {
snprintf(dst, koko, "Moi, %s!", nimi);
}
Artikkeli kokoaa asiat käteväksi kokonaisuudeksi: luetaan käyttäjätunnus, siivotaan rivinvaihto, tarkistetaan pituus ja normalisoidaan merkkijono pieniksi kirjaimiksi.
#include <stdio.h>
#include <string.h>
#include <ctype.h>
#define TUNNUS_KOKO 32
int main(void) {
char tunnus[TUNNUS_KOKO];
printf("Anna käyttäjätunnus: ");
if (!fgets(tunnus, sizeof(tunnus), stdin)) {
return 1;
}
tunnus[strcspn(tunnus, "\n")] = '\0';
if (strlen(tunnus) == 0) {
printf("Tunnus ei voi olla tyhjä.\n");
return 1;
}
for (size_t i = 0; tunnus[i] != '\0'; i++) {
tunnus[i] = (char)tolower((unsigned char)tunnus[i]);
}
printf("Normalisoitu tunnus: %s\n", tunnus);
return 0;
}
Tässä pienessä esimerkissä näkyy neljä hyvää tapaa yhdessä: kiinteäkokoinen puskuri nimetyn vakion kanssa, turvallinen syötteenluku, rivinvaihdon poisto ja nollatavun käyttö silmukan lopetusehtona.
C:n merkkijonot eivät ole vaikeita. On muistettava yksi asia: kyse on aina tavutaulukosta ja sen lopussa olevasta nollatavusta. Suurin osa virheistä syntyy siitä, että tämä sopimus unohtuu jossain kohtaa koodia. Kun puskurin koko on aina tiedossa ja nollatavu aina paikallaan, loppu on kirjastofunktioiden hoidettavissa.
Ensimmäinen kirjoitus on hankalin. Kaikki tuntuu olevan yhtä pitkää kuin leveää. Mikä oikeastaan on todella tärkeää, laadukasta ja ilmoittamisen arvoista interneteissä.
Ensimmäinen kirjoitus on hankalin. Kaikki tuntuu olevan yhtä pitkää kuin leveää. Mikä oikeastaan on todella tärkeää, laadukasta ja ilmoittamisen arvoista interneteissä.
Itseänä kunnioittavana nörttinä päätin sitten tutkia, kokeilla ja tehdä oman julkaisujärjestelmän. Internetistä löytyy monia moottoreita, jotka ovat kehittyneempiä ja tarjoavat tukea useammallekin tavalle tunkea sisältöä internetteihin. Oman julkaisumoottorin tekeminen PHP:lla on oma projektinsa.
Tämä ei jää tähän! Interneteistä löytyy haasteellisimpia projekteja, joihin
minulla ei todennäköisesti ole tarpeeksi osaamista. Ei se ole ennen ollut este.
Tarjonnut hyvän tekosyyn juoda kahvia ja rupatella kolleegojen kanssa aiheista, jotka
oikeasti merkitsevät. Kahvin määrästä ja laadusta riippuen löytyy lukuisia ratkaisuita.
Käytämme evästeitä sivuston käytön analysointiin ja kokemuksen parantamiseen. Hyväksymällä sallit analytiikkaevästeet.