5 Minute Snack: Reading floats with StrToFloat and TFormatSettings independent of system region settings
This is one of the topics that sadly shows up mostly at run-time when you already deployed the app to your customers.
We all have our development machine. Our application works just fine. And then we deploy to the customer and the customer says that he gets an error that “12,5 is not a valid float value”.
Why is it 12,5 you wonder… on your system it is always 12.5 and works just fine.
Well, different regions different ways to write a number. In Europe most countries use “,” (comma) as a decimal separator and “.” as a thousand separator. In the United States of America it is exactly vice versa.
It gets even more complicated if you have to read a number from a string that is produced by a device that always formats numbers a certain way no matter what and you have to make sure that your application is able to interpret these numbers.
There is always lots of information on the web when it comes to Delphi and its functions. In order to convert a string to a float value you use StrToFloat . Sadly, most of the information is outdated. Recent versions of Delphi use an approach that has been established by Java and .NET and has found its way into the Delphi RTL.
Here’s how Delphi tries to parse a string into a float: Delphi uses the local regional settings of the user profile the application runs in. This means if your application runs on a US-based system without any individual changes the decimal separator most likely will be “.”. If your application is run on a system in Germany, Delphi will use “,” as a decimal separator.
How you say what Delphi shall use: Let me just start with this. I have seen so many strange things to solvet his issue, it made me dizzy sometimes. There’s people rewriting registry settings to get it done and trying to load different regional configurations using the Win API… the sky seems to be the limit.
However, the solution is so easy if you have a look at the different fingerprints of the StrToFloat method:
function StrToFloat(const S: string): Extended; overload; inline; function StrToFloat(const S: string; const AFormatSettings: TFormatSettings): Extended; overload;
There is the option to specify a setting of type TFormatSettings as the second parameter for the parse operation. And that is already the solution. TFormatSettings is a record that is designed to hold regional settings of all sorts. Date and Time formats, day names, number formats — all you need.
And if you look at the source….
type TFormatSettings = record public CurrencyString: string; CurrencyFormat: Byte; CurrencyDecimals: Byte; DateSeparator: Char; TimeSeparator: Char; ListSeparator: Char; ShortDateFormat: string; LongDateFormat: string; TimeAMString: string; TimePMString: string; ShortTimeFormat: string; LongTimeFormat: string; ShortMonthNames: array[1..12] of string; LongMonthNames: array[1..12] of string; ShortDayNames: array[1..7] of string; LongDayNames: array[1..7] of string; EraInfo: array of TEraInfo; ThousandSeparator: Char; DecimalSeparator: Char; TwoDigitYearCenturyWindow: Word; NegCurrFormat: Byte; /// <summary> /// This is a candidate to be removed or left to store the Locale that created the FormatSettings. /// </summary> NormalizedLocaleName: string; /// <summary> /// Creates a TFormatSettings record with current default values provided /// by the operating system. /// </summary> class function Create: TFormatSettings; overload; static; inline; /// <summary> /// Creates a TFormatSettings record with values provided by the operating /// system for the specified locale. The locale is an LCID on Windows /// platforms, if using LIB_ICU is a MarshaledAString, or a locale_t on Posix platforms. /// </summary> class function Create(Locale: TLocaleID): TFormatSettings; overload; platform; static; /// <summary> /// Creates a TFormatSettings record with values provided by the operating /// system for the specified locale name in the "Language-Country" format. /// Example: 'en-US' for U.S. English settings or 'en-GB' for UK English settings. /// </summary> class function Create(const LocaleName: string): TFormatSettings; overload; static; /// <summary> /// Creates a TFormatSettings record with standard values, they are associated with the English /// language but not with any country/region /// </summary> class function Invariant: TFormatSettings; static; // function GetEraYearOffset(const Name: string): Integer; end;
…you even get information what different methods can be used to fill the record with data.
If you know that your external device will deliver measurement data in US format all the time, i.e. with ‘.’ as a decimal separator and you want to make sure that Delphi knows it you need to create TFormatSettings either with a US-locale or you can also create one based on your system and set the delimiter manually. You have the option.
Here we create the record for US settings:
LFormat := TFormatSettings.Create( 'en-US' );
LFormat is of type TFormatSettings . Mind that it does not have to be released as it is a record and not an object instance; even though the method is called create it is not a constructor that is called.
Alternatively, you can say:
LFormat := TFormatSettings.Create; LFormat.DecimalSeparator := '.'; LFormat.ThousandSeparator := ',';
So, whenever you encounter a string that has “12.5” as a number it will be able to parse that number no matter on what system your application runs.
Once you know that you have TFormatSettings at your disposal it becomes really easy.
I tend to use TFormatSettings.Invariant, which is always the same on all locales and specifically designed for this purpose.
TFormatSettings.Invariant is (almost?) the same as US settings and predefined, so no need to create it or set any members.
Really interesting
thank you
Pierpaolo
Do you know if there is a plan for fixing the nasty bug in TFormatSettings? Date- or Time-Separator can not be empty. Means you can not parse an YYYYMMDD date
No, I do not.
The same kind of problem arises when reading floating point numbers from INI files. As a work-around I simply use my functions ReadIniFloat (one for each of the three float types Single, Double and Extended). If the global variable DecimalSeparator is ‘,’ then OtherDecimalSeparator will be set to ‘.’ and vice versa:
function ReadIniFloat(MemIniFile:TMemIniFile; OtherDecimalSeparator:Char; Section,VariableName:string; DefaultValue:Single ):Single ; overload;
var ValueString:string;
begin
ValueString:=MemIniFile.ReadString(Section,VariableName,FloatToStr(DefaultValue));
Result:=StrToFloat(StringReplace(ValueString,OtherDecimalSeparator,DecimalSeparator,[]));
end;
function ReadIniFloat(MemIniFile:TMemIniFile; OtherDecimalSeparator:Char; Section,VariableName:string; DefaultValue:Double ):Double ; overload;
var ValueString:string;
begin
ValueString:=MemIniFile.ReadString(Section,VariableName,FloatToStr(DefaultValue));
Result:=StrToFloat(StringReplace(ValueString,OtherDecimalSeparator,DecimalSeparator,[]));
end;
function ReadIniFloat(MemIniFile:TMemIniFile; OtherDecimalSeparator:Char; Section,VariableName:string; DefaultValue:Extended):Extended; overload;
var ValueString:string;
begin
ValueString:=MemIniFile.ReadString(Section,VariableName,FloatToStr(DefaultValue));
Result:=StrToFloat(StringReplace(ValueString,OtherDecimalSeparator,DecimalSeparator,[]));
end;
(The reason for using TMemIniFile instead of TIniFile is simply because with TIniFile the INI file will be opened and closed over and over again for every individual ReadBool, ReadInteger, ReadFloat and ReadString operation.)
When writing my INI files I simply use the decimal separator as defined in the local settings.
This work-around ensures that there will be no problems, even if you deploy an INI file written on a decimal point machine to a decimal comma machine and vice versa.