Ohjelmointi 12. elokuuta 2026

C:n merkkijonot — käytännön opas

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.

Mikä merkkijono oikeasti on

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.

Nollatavu ratkaisee kaiken

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ä.

Taulukko vai osoitin?

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.

Merkkijonovakiot ovat vain luku-oikeudella

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";

Kopiointi, vertailu ja pituus

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äFunktioMuistiohuomautus/th>
PituusstrlenEi laske mukaan lopetusmerkkiä
KopiointistrcpyKohteen on oltava riittävän suuri
VertailustrcmpPalauttaa 0, kun merkkijonot ovat samat
YhdistelystrcatKohteessa 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
}

UTF-8 ja ääkköset

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ä.

Syötteen lukeminen turvallisesti

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ä.

Yleisimmät sudenkuopat

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 ja merkkijonot

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.

Käytännöt, joilla säästät itsesi likaamiselta

void tervehdi(char *dst, size_t koko, const char *nimi) {
    snprintf(dst, koko, "Moi, %s!", nimi);
}

Pieni esimerkkiohjelma

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.

Yhteenveto

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.