Dit artikel bespreekt de complexiteit en het gebrek aan portabiliteit bij het afhandelen van fouten in C-functies voor string-naar-float conversies, zoals strtod en strtof. De auteur legt uit dat er significante verschillen zijn tussen de C-standaard, die via de math_errhandling macro bepaalt of errno of floating-point exceptions worden gebruikt, en de POSIX-standaard, die altijd het gebruik van errno vereist.
Daarnaast wordt ingegaan op de problematiek rondom ongeldige invoer. Terwijl de C-standaard voorschrijft om de endptr parameter te gebruiken om te controleren of er een conversie heeft plaatsgevonden, suggereert POSIX dat errno op EINVAL gezet kan worden. De auteur toont aan dat implementaties zoals glibc en musl hierin verschillen, wat het onmogelijk maakt om enkel op errno te vertrouwen.
Het artikel concludeert met een praktisch overzicht van de 'correcte' methoden om overflow, underflow en ongeldige invoer te detecteren, afhankelijk van of de ontwikkelaar target op POSIX, een specifieke implementatie of de algemene C-standaard.
Blijkbaar is het niet mogelijk om op een portable manier fouten bij string-naar-float conversies te controleren in standaard C
Dit is een soort vervolg op mijn vorige post, waarin ik inging op de math_errhandling macro en hoe foutafhandeling voor wiskundige functies wordt geregeld in glibc en musl (evenals hoe dit is vastgelegd in de standaard).
Om kort samen te vatten: matherrhandling is een macro die aangeeft welke mechanismen voor foutafhandeling worden ondersteund door functies in math.h: errno (MATHERRNO) en/of floating-point exceptions (MATH_ERREXCEPT).
Er is één ding dat ik in mijn vorige post niet heb genoemd: er is een familie van functies die wordt beïnvloed door math_errhandling, maar die zich niet in math.h bevindt. Dit zijn de functies voor de conversie van strings naar floats: strtod, strtof, strtold, strtod32, strtod64 en strtod128.
De standaard (7.25.2.6p12) stelt het volgende:
Als de correcte waarde overloopt (overflow) en de standaard afronding van kracht is (7.12.2), wordt plus of minus HUGEVAL, HUGEVALF, of HUGEVALL geretourneerd (afhankelijk van het returntype en het teken van de waarde); als de integer-expressie matherrhandling & MATHERRNO ongelijk is aan nul, krijgt de integer-expressie errno de waarde ERANGE; als de integer-expressie matherrhandling & MATH_ERREXCEPT ongelijk is aan nul, wordt de "overflow" floating-point exception aangeroepen.
Als het resultaat onderloopt (underflow) (7.12.2), retourneren de functies een waarde waarvan de magnitude niet groter is dan het kleinste genormaliseerde positieve getal in het returntype; als de integer-expressie matherrhandling & MATHERRNO ongelijk is aan nul, is het implementatie-afhankelijk of errno de waarde ERANGE krijgt; als de integer-expressie matherrhandling & MATHERREXCEPT ongelijk is aan nul, is het implementatie-afhankelijk of de "underflow" floating-point exception wordt aangeroepen.
De Linux man-pages voor deze functies vermelden dit letterlijk helemaal niet. Daar is een reden voor, waar ik zo op terugkom. Wat de standaard echter zegt, is dat als math_errhandling geen ondersteuning voor errno adverteert (wat bijvoorbeeld het geval is bij musl), de functies voor string-naar-float conversie geen errno instellen bij een fout. Bovendien is de functie niet verplicht een fout te rapporteren als het resultaat onderloopt.
Houd er rekening mee dat de retourwaarde alleen niet voldoende is om te bepalen of er een fout is opgetreden. Om overflow te testen, moet je dus gebruikmaken van een van de twee mechanismen voor foutafhandeling.
Hier is mijn poging tot een door de standaard goedgekeurde manier om op portable wijze overflow- en underflow-fouten in de string-naar-float functies te controleren:
feclearexcept(FE_OVERFLOW | FE_UNDERFLOW);
errno = 0;
double x = strtod(s, nullptr);
bool errored = (math_errhandling & MATH_ERRNO)
? errno == ERANGE
: fetestexcept(FE_OVERFLOW | FE_UNDERFLOW);
Let op dat dit nog steeds geen garantie biedt dat underflow wordt gedetecteerd, aangezien het rapporteren van underflow volledig optioneel is voor de implementatie.
De POSIX-benadering
De reden waarom je dit waarschijnlijk nooit hebt gedaan (en waarom de man-pages het niet vermelden), is dat POSIX deze functies anders specificeert:
Als de correcte waarde buiten het bereik van representeerbare waarden ligt, moeten $\pm\text{HUGE\VAL}$, $\pm\text{HUGE\VALF}$, of $\pm\text{HUGE\_VALL}$ worden geretourneerd (afhankelijk van het teken van de waarde), en moet errno worden ingesteld op [ERANGE].
Als de correcte waarde zou leiden tot een underflow, moet een waarde worden geretourneerd waarvan de magnitude niet groter is dan het kleinste genormaliseerde positieve getal in het returntype, en moet errno worden ingesteld op [ERANGE].
POSIX houdt dus geen rekening met math_errhandling; het vereist altijd dat de implementatie errno instelt bij overflow of underflow. Hoewel het niet ongebruikelijk is dat POSIX striktere eisen stelt aan functies dan standaard C, is het opvallend dat de man-page nooit vermeldt dat dit een uitbreiding is (noch in strtod(3) noch in de POSIX-specificatie strtod(3p)).
Compatibiliteit tussen POSIX en Standaard C
Of het gedrag van POSIX überhaupt compatibel is met standaard C is onduidelijk. Voor functies in math.h is het in ieder geval toegestaan om errno in te stellen, ongeacht de waarde van math_errhandling:
7.12.2p8: Als een domein-, pool- of bereikfout optreedt en de integer-expressie matherrhandling & MATHERRNO nul is, dan moet errno ofwel worden ingesteld op de waarde die overeenkomt met de fout, of ongewijzigd blijven.
Maar eerder, bij de specificatie van errno.h, zegt de standaard dit:
7.5p3: [...] De waarde van errno kan worden ingesteld op een waarde ongelijk aan nul door een aanroep van een bibliotheekfunctie, ongeacht of er een fout is, mits het gebruik van errno niet is gedocumenteerd in de beschrijving van de functie in dit document.
errno is gedocumenteerd in de beschrijving van deze functies, en die beschrijving vermeldt niets over het instellen van errno wanneer matherrhandling & MATHERRNO nul is. Dit suggereert dat het gedrag van POSIX niet-conform is.
Ongeldige invoer (geen conversie)
Alles tot nu toe ging over overflow en underflow. Maar als de string malformed is en niet als getal kan worden geparseerd, specificeert de standaard helemaal geen fout:
7.25.2.6p11: De functies retourneren de geconverteerde waarde, indien aanwezig. Als er geen conversie kon worden uitgevoerd, wordt een positieve of ongetekende nul geretourneerd.
In plaats daarvan moet je de endptr parameter gebruiken en daarna controleren of endptr == nptr (wat betekent dat de eindpointer gelijk is aan de startpointer en er dus geen data is geparseerd):
7.25.2.6p8: Als de subject-sequentie leeg is of niet de verwachte vorm heeft, wordt er geen conversie uitgevoerd; de waarde van nptr wordt opgeslagen in het object waar endptr naar verwijst, mits endptr geen null pointer is.
De man-page strtod(3) zegt iets soortgelijks:
Als er geen conversie wordt uitgevoerd, wordt nul geretourneerd en (tenzij endptr null is) wordt de waarde van nptr opgeslagen op de locatie waar endptr naar verwijst.
Maar kijk wat POSIX zegt:
Bij succesvolle voltooiing retourneren deze functies de geconverteerde waarde. Als er geen conversie kon worden uitgevoerd, wordt 0 geretourneerd, en errno kan worden ingesteld op [EINVAL].
Het woord "kan" betekent in feite dat het implementatie-afhankelijk is. Dit is echter een groot probleem, omdat het heel gebruikelijk is om fouten te controleren via een methode zoals deze:
errno = 0;
double x = strtod(s, nullptr);
if (errno != 0) {
// ...
}
De strtod(3) man-page suggereert zelfs om precies dit te doen:
Aangezien 0 legitiem kan worden geretourneerd bij zowel succes als falen, moet het aanroepende programma errno op 0 zetten voor de aanroep, en vervolgens bepalen of er een fout is opgetreden door te controleren of errno na de aanroep een waarde ongelijk aan nul heeft.
Maar zelfs voor POSIX-compatibele libc's is dit niet portable! Verschillende conformistische libc's kunnen verschillend reageren als er geen conversie kan worden uitgevoerd. In feite:
errno = 0;
strtod("x", nullptr);
printf("%d\n", errno);
Op glibc print dit 0, omdat de strtod van glibc nooit errno instelt op EINVAL. Musl daarentegen stelt errno wel in op EINVAL, dus dit print "22". Dit is volledig ongedocumenteerd in de Linux man-page.
Vanuit mijn lezing van de standaard is dit ook niet conform aan standaard C, omdat errno wordt ingesteld op een waarde ongelijk aan nul in een functie met andere gedocumenteerde foutcondities (en voor zover standaard C het betreft, is ongeldige invoer geen foutconditie). Musl zou kunnen aanvoeren dat omdat het errno niet instelt in zijn math.h functies, de errno-conditie in de beschrijving van strtod niet langer van toepassing is, en er dus geen gedocumenteerd gebruik van errno is. Maar dat is duidelijk een vergezocht argument.
Hoe dan ook, het is duidelijk dat de standaard hier duidelijkere bewoordingen nodig heeft.
Conclusie
Om het punt te onderstrepen, volgt hier een lijst met alle "correcte" manieren om fouten te controleren bij de string-naar-float conversiefuncties:
Overflow
- Bij POSIX-targeting: Stel
errno in op 0 voor de aanroep, en controleer daarna of copysign(result, 1.0) == HUGE_VAL && errno == ERANGE.
- Anders: Stel
errno in op 0 en roep feclearexcept(FEOVERFLOW) aan voor de aanroep. Als copysign(result, 1.0) == HUGEVAL, controleer dan na de aanroep of errno == ERANGE of fetestexcept(FEOVERFLOW), afhankelijk van de waarde van matherrhandling.
- Bij targeting van één specifieke implementatie: Als je vooraf weet welke foutrapportage-methoden worden ondersteund, kun je de
math_errhandling check overslaan.
- Bij gebruik van
feclearexcept/fetestexcept: Zorg dat de compiler weet dat je de floating point environment mogelijk accesseert. In GCC is de relevante flag -ftrapping-math (standaard, tenzij -ffast-math wordt gebruikt). Er is ook een standaard pragma: #pragma STDC FENV_ACCESS ON, hoewel dit niet wordt ondersteund door GCC.
Underflow
- Bij POSIX-targeting: Stel
errno in op 0 voor de aanroep, en controleer daarna of result == 0.0 && errno == ERANGE. Controleer specifiek op ERANGE, niet alleen of errno ongelijk is aan nul, om non-portable gedrag te voorkomen.
- Bij targeting van één specifieke implementatie: Controleer of de documentatie het gedrag bij underflow in
math.h functies beschrijft. Dit is implementatie-afhankelijk.
- Indien gedocumenteerd en gerapporteerd: Afhankelijk van de
matherrhandling waarde: stel errno in op 0 en check result == 0.0 && errno == ERANGE, of gebruik feclearexcept(FEUNDERFLOW) en check fetestexcept(FE_UNDERFLOW).
- Alternatief: Als je echt underflow moet detecteren en de libc-functie moet gebruiken: controleer of
result == 0.0, en zo ja, controleer zelf of de input-string nonzero cijfers bevat vóór de exponent. Dit is lastig correct te implementeren; lees de beschrijving van strtod in de standaard om alle edge-cases te dekken.
Ongeldige invoer (geen conversie)
- Geef
&endptr mee als tweede argument aan de functie en controleer vervolgens of endptr == nptr. Men kan niet vertrouwen op errno.
Blijkbaar is het niet mogelijk om op een portable manier fouten bij string-naar-float conversies te controleren in standaard C
Dit is een soort vervolg op mijn vorige post, waarin ik inging op de math_errhandling macro en hoe foutafhandeling voor wiskundige functies wordt geregeld in glibc en musl (evenals hoe dit is vastgelegd in de standaard).
Om kort samen te vatten: matherrhandling is een macro die aangeeft welke mechanismen voor foutafhandeling worden ondersteund door functies in math.h: errno (MATHERRNO) en/of floating-point exceptions (MATH_ERREXCEPT).
Er is één ding dat ik in mijn vorige post niet heb genoemd: er is een familie van functies die wordt beïnvloed door math_errhandling, maar die zich niet in math.h bevindt. Dit zijn de functies voor de conversie van strings naar floats: strtod, strtof, strtold, strtod32, strtod64 en strtod128.
De standaard (7.25.2.6p12) stelt het volgende:
Als de correcte waarde overloopt (overflow) en de standaard afronding van kracht is (7.12.2), wordt plus of minus HUGEVAL, HUGEVALF, of HUGEVALL geretourneerd (afhankelijk van het returntype en het teken van de waarde); als de integer-expressie matherrhandling & MATHERRNO ongelijk is aan nul, krijgt de integer-expressie errno de waarde ERANGE; als de integer-expressie matherrhandling & MATH_ERREXCEPT ongelijk is aan nul, wordt de "overflow" floating-point exception aangeroepen.
Als het resultaat onderloopt (underflow) (7.12.2), retourneren de functies een waarde waarvan de magnitude niet groter is dan het kleinste genormaliseerde positieve getal in het returntype; als de integer-expressie matherrhandling & MATHERRNO ongelijk is aan nul, is het implementatie-afhankelijk of errno de waarde ERANGE krijgt; als de integer-expressie matherrhandling & MATHERREXCEPT ongelijk is aan nul, is het implementatie-afhankelijk of de "underflow" floating-point exception wordt aangeroepen.
De Linux man-pages voor deze functies vermelden dit letterlijk helemaal niet. Daar is een reden voor, waar ik zo op terugkom. Wat de standaard echter zegt, is dat als math_errhandling geen ondersteuning voor errno adverteert (wat bijvoorbeeld het geval is bij musl), de functies voor string-naar-float conversie geen errno instellen bij een fout. Bovendien is de functie niet verplicht een fout te rapporteren als het resultaat onderloopt.
Houd er rekening mee dat de retourwaarde alleen niet voldoende is om te bepalen of er een fout is opgetreden. Om overflow te testen, moet je dus gebruikmaken van een van de twee mechanismen voor foutafhandeling.
Hier is mijn poging tot een door de standaard goedgekeurde manier om op portable wijze overflow- en underflow-fouten in de string-naar-float functies te controleren:
feclearexcept(FE_OVERFLOW | FE_UNDERFLOW);
errno = 0;
double x = strtod(s, nullptr);
bool errored = (math_errhandling & MATH_ERRNO)
? errno == ERANGE
: fetestexcept(FE_OVERFLOW | FE_UNDERFLOW);
Let op dat dit nog steeds geen garantie biedt dat underflow wordt gedetecteerd, aangezien het rapporteren van underflow volledig optioneel is voor de implementatie.
De POSIX-benadering
De reden waarom je dit waarschijnlijk nooit hebt gedaan (en waarom de man-pages het niet vermelden), is dat POSIX deze functies anders specificeert:
Als de correcte waarde buiten het bereik van representeerbare waarden ligt, moeten $\pm\text{HUGE\VAL}$, $\pm\text{HUGE\VALF}$, of $\pm\text{HUGE\_VALL}$ worden geretourneerd (afhankelijk van het teken van de waarde), en moet errno worden ingesteld op [ERANGE].
Als de correcte waarde zou leiden tot een underflow, moet een waarde worden geretourneerd waarvan de magnitude niet groter is dan het kleinste genormaliseerde positieve getal in het returntype, en moet errno worden ingesteld op [ERANGE].
POSIX houdt dus geen rekening met math_errhandling; het vereist altijd dat de implementatie errno instelt bij overflow of underflow. Hoewel het niet ongebruikelijk is dat POSIX striktere eisen stelt aan functies dan standaard C, is het opvallend dat de man-page nooit vermeldt dat dit een uitbreiding is (noch in strtod(3) noch in de POSIX-specificatie strtod(3p)).
Compatibiliteit tussen POSIX en Standaard C
Of het gedrag van POSIX überhaupt compatibel is met standaard C is onduidelijk. Voor functies in math.h is het in ieder geval toegestaan om errno in te stellen, ongeacht de waarde van math_errhandling:
7.12.2p8: Als een domein-, pool- of bereikfout optreedt en de integer-expressie matherrhandling & MATHERRNO nul is, dan moet errno ofwel worden ingesteld op de waarde die overeenkomt met de fout, of ongewijzigd blijven.
Maar eerder, bij de specificatie van errno.h, zegt de standaard dit:
7.5p3: [...] De waarde van errno kan worden ingesteld op een waarde ongelijk aan nul door een aanroep van een bibliotheekfunctie, ongeacht of er een fout is, mits het gebruik van errno niet is gedocumenteerd in de beschrijving van de functie in dit document.
errno is gedocumenteerd in de beschrijving van deze functies, en die beschrijving vermeldt niets over het instellen van errno wanneer matherrhandling & MATHERRNO nul is. Dit suggereert dat het gedrag van POSIX niet-conform is.
Ongeldige invoer (geen conversie)
Alles tot nu toe ging over overflow en underflow. Maar als de string malformed is en niet als getal kan worden geparseerd, specificeert de standaard helemaal geen fout:
7.25.2.6p11: De functies retourneren de geconverteerde waarde, indien aanwezig. Als er geen conversie kon worden uitgevoerd, wordt een positieve of ongetekende nul geretourneerd.
In plaats daarvan moet je de endptr parameter gebruiken en daarna controleren of endptr == nptr (wat betekent dat de eindpointer gelijk is aan de startpointer en er dus geen data is geparseerd):
7.25.2.6p8: Als de subject-sequentie leeg is of niet de verwachte vorm heeft, wordt er geen conversie uitgevoerd; de waarde van nptr wordt opgeslagen in het object waar endptr naar verwijst, mits endptr geen null pointer is.
De man-page strtod(3) zegt iets soortgelijks:
Als er geen conversie wordt uitgevoerd, wordt nul geretourneerd en (tenzij endptr null is) wordt de waarde van nptr opgeslagen op de locatie waar endptr naar verwijst.
Maar kijk wat POSIX zegt:
Bij succesvolle voltooiing retourneren deze functies de geconverteerde waarde. Als er geen conversie kon worden uitgevoerd, wordt 0 geretourneerd, en errno kan worden ingesteld op [EINVAL].
Het woord "kan" betekent in feite dat het implementatie-afhankelijk is. Dit is echter een groot probleem, omdat het heel gebruikelijk is om fouten te controleren via een methode zoals deze:
errno = 0;
double x = strtod(s, nullptr);
if (errno != 0) {
// ...
}
De strtod(3) man-page suggereert zelfs om precies dit te doen:
Aangezien 0 legitiem kan worden geretourneerd bij zowel succes als falen, moet het aanroepende programma errno op 0 zetten voor de aanroep, en vervolgens bepalen of er een fout is opgetreden door te controleren of errno na de aanroep een waarde ongelijk aan nul heeft.
Maar zelfs voor POSIX-compatibele libc's is dit niet portable! Verschillende conformistische libc's kunnen verschillend reageren als er geen conversie kan worden uitgevoerd. In feite:
errno = 0;
strtod("x", nullptr);
printf("%d\n", errno);
Op glibc print dit 0, omdat de strtod van glibc nooit errno instelt op EINVAL. Musl daarentegen stelt errno wel in op EINVAL, dus dit print "22". Dit is volledig ongedocumenteerd in de Linux man-page.
Vanuit mijn lezing van de standaard is dit ook niet conform aan standaard C, omdat errno wordt ingesteld op een waarde ongelijk aan nul in een functie met andere gedocumenteerde foutcondities (en voor zover standaard C het betreft, is ongeldige invoer geen foutconditie). Musl zou kunnen aanvoeren dat omdat het errno niet instelt in zijn math.h functies, de errno-conditie in de beschrijving van strtod niet langer van toepassing is, en er dus geen gedocumenteerd gebruik van errno is. Maar dat is duidelijk een vergezocht argument.
Hoe dan ook, het is duidelijk dat de standaard hier duidelijkere bewoordingen nodig heeft.
Conclusie
Om het punt te onderstrepen, volgt hier een lijst met alle "correcte" manieren om fouten te controleren bij de string-naar-float conversiefuncties:
Overflow
- Bij POSIX-targeting: Stel
errno in op 0 voor de aanroep, en controleer daarna of copysign(result, 1.0) == HUGE_VAL && errno == ERANGE.
- Anders: Stel
errno in op 0 en roep feclearexcept(FEOVERFLOW) aan voor de aanroep. Als copysign(result, 1.0) == HUGEVAL, controleer dan na de aanroep of errno == ERANGE of fetestexcept(FEOVERFLOW), afhankelijk van de waarde van matherrhandling.
- Bij targeting van één specifieke implementatie: Als je vooraf weet welke foutrapportage-methoden worden ondersteund, kun je de
math_errhandling check overslaan.
- Bij gebruik van
feclearexcept/fetestexcept: Zorg dat de compiler weet dat je de floating point environment mogelijk accesseert. In GCC is de relevante flag -ftrapping-math (standaard, tenzij -ffast-math wordt gebruikt). Er is ook een standaard pragma: #pragma STDC FENV_ACCESS ON, hoewel dit niet wordt ondersteund door GCC.
Underflow
- Bij POSIX-targeting: Stel
errno in op 0 voor de aanroep, en controleer daarna of result == 0.0 && errno == ERANGE. Controleer specifiek op ERANGE, niet alleen of errno ongelijk is aan nul, om non-portable gedrag te voorkomen.
- Bij targeting van één specifieke implementatie: Controleer of de documentatie het gedrag bij underflow in
math.h functies beschrijft. Dit is implementatie-afhankelijk.
- Indien gedocumenteerd en gerapporteerd: Afhankelijk van de
matherrhandling waarde: stel errno in op 0 en check result == 0.0 && errno == ERANGE, of gebruik feclearexcept(FEUNDERFLOW) en check fetestexcept(FE_UNDERFLOW).
- Alternatief: Als je echt underflow moet detecteren en de libc-functie moet gebruiken: controleer of
result == 0.0, en zo ja, controleer zelf of de input-string nonzero cijfers bevat vóór de exponent. Dit is lastig correct te implementeren; lees de beschrijving van strtod in de standaard om alle edge-cases te dekken.
Ongeldige invoer (geen conversie)
- Geef
&endptr mee als tweede argument aan de functie en controleer vervolgens of endptr == nptr. Men kan niet vertrouwen op errno.