TCalculator
Marley Software
· Pablo Pissanetzky
Description
TCalculator is a class that solves mathematical expressions. It encapsulates a parser, tokenizer and syntax analyzer and provides a simple interface for extension. It allows you to define symbols ( variables ) and provide a way to resolve them so they can be included in expressions. It also allows you to easily specify custom functions. As it is, it can solve expressions that include the following operators:
+ Addition
- Subtraction
* Multiplication
/ Division
= < > <= >= <> Logical operators
And the following functions:
Trunc , Round and Abs
What does it do?
You simply pass TCalculator a string and it returns you another string with the result. So, for example, you can pass it the string "1+1" and it will return you a string "2" ( without the quotes ). TCalculator takes your expression, parses it and then attempts to solve it. If an error occurs, it will throw an exception with a message describing the error. The Solve function accomplishes all this:
function Solve( const Formula : string; var ReturnType :
TValueType ) : string;
It takes a Formula ( or expression ) and returns a string with the result. TCalculator
recognizes two types of literals: numbers and strings. Strings are enclosed in double
quotes, and all others are considered numbers. The + operator can work with both strings
and numbers, as it will concatenate the two input strings. The ReturnType of the Solve
function is the type of the Result, vtNumber or vtString in most cases.
For example:
1+1
returns 2
"Hello" + " world!" returns
Hello world!
Identifiers
TCalculator can also handle identifiers or variables that must begin with a letter but can contain numbers. By default, TCalculator raises an exception when it encounters an identifier, but you can override a single method to provide TCalculator with a means to resolve the identifier to a valid value. To do this, simply create a descendant of TCalculator and override the ResolveIdentifier function like this:
TTestCalculator = class( TCalculator )
protected
function ResolveIdentifier( const Identifier : string; var ValueType :
TValueType; var Value : string ) : Boolean; override;
end;
The resolve identifier method is called whenever a variable is encountered in the
expression, and you must let TCalculator know what the variable means in order to solve
the expression. The identifier is passed as a string in the Identifier parameter. You must
fill in the ValueType and Value arguments, and return a Boolean result indicating success
or failure.
For example, lets say you have the following expression :
x + y.
You also know that x is actually 3.14 and y is 10. You override the ResolveIdentifier
function as follows:
function TTestCalculator.ResolveIdentifier( const
Identifier : string; var ValueType : TValueType; var Value : string ) : Boolean;
begin
if Identifier = 'x' then
begin
ValueType := vtNumber;
Value := '3.14';
Result := True;
end
else if Identifier = 'y' then
begin
ValueType := vtNumber;
Value := '10';
Result := True;
end
else
Result := False;
end;
This will furnish TCalculator with the actual values for x and y and will raise an exception if any other identifier is encountered. This example is not very flexible, in most cases you will have a way to look up an identifier and find its type and value. The example project uses a simple Memo, and takes advantages of TStringList's Values property as follows:
function TTestCalculator.ResolveIdentifier( const
Identifier : string; var ValueType : TValueType; var Value : string ) : Boolean;
begin
Value := Trim( Form1.Memo2.Lines.Values[ Identifier ] );
Result := True;
if Length( Value ) = 0 then
ValueType := vtEmpty
else
begin
try
StrToFloat( Value );
ValueType := vtNumber;
except
ValueType := vtString;
end;
end;
end;
The Values property lets you get a named value from a TStringList. So if the list contains an entry like "y=10", then Memo.Lines.Values[ 'y' ] will return the string "10". If no such value exists, the Values property returns an empty string. In this case, first we get the Value, if it is empty, we can use the vtEmpty value type otherwise, we try to convert it to a float. If that succeeds, we return vtNumber as the value type. Otherwise, we say that the value is of type vtString. In this case, you can use any identifier because if it is not present, ResolveIdentifier will simply return a Result of True and a ValueType of vtEmpty.
Try this with the example project: Enter the following strings in the Memo labeled "Expressions" in two separate lines, with no quotes or spaces : "x=3.14" and "y=10". Then enter the formula "x+y" and press return. You will see the result "13.14" in the Results memo. Then enter the formula "x+a" and press return. The result stays the same, and no error was shown. This is because the identifier "a" was not found, and a value type of vtEmpty was returned by the ResolveIdentifier function. All of the built in operators can handle empty values and most treat them as "0". This is something to consider when you begin writing custom functions for TCalculator. See the table below.
Operator | vtNumber | vtString | vtEmpty |
+ | Floating point addition | Concatenation | Zero or empty string |
- ( binary ) | Floating point subtraction | N/A | Zero |
* | Floating point multiplication | N/A | Zero |
/ | Floating point division | N/A | Zero |
- ( unary ) | Negative | N/A | Zero |
TRUNC | Truncation | N/A | Zero |
ABS | Absolute value | N/A | Zero |
ROUND | Rounds up above .50 | N/A | Zero |
The logical operators can take arguments of any type and perform comparisons. They return a value of type string, which can be either "True" or "False" Note that the equality operator returns True if both arguments are vtEmpty.
Custom Functions
TCalculator also recognizes functions in the expression. The built in examples are Trunc, Round and Abs, and you can easily add your own. A function is an identifier followed by an open parenthesis, arguments separated by commas and a close parenthesis. For example : "Round( 10 )" or "Average( 1 , 5 )" are valid functions. Note that each argument can itself be an expression, so "Abs( Trunc( a / 3 ) )" is perfectly valid.
When TCalculator encounters a function, it has to determine whether it is a valid function, how many arguments it takes and what kind of arguments are valid for it. It does this by calling the LookupFunction method. This method is protected and virtual, and handles the built-in operators and functions.( Note that an operator is just like a function, only it takes two arguments ). So, when TCalculator encounters the function call "average( 1 , 10 )" it calls LookupFunction to get the information it needs, passing "average" as the FunctionName. LookupFunction has the following prototype:
function LookupFunction( const FunctionName : string; var MinArgCount , MaxArgCount : Integer; var ValueTypes : TValueTypes ) : Boolean; virtual;
It passes the FunctionName as found in the expression. It expects the MinArgCount, MaxArgCount and ValueTypes parameters and a Result of True if the function is valid. The argument parametesr are simply the number of arguments that the function needs and can be zero. You can specify that the function can take anywhere from MinArgCount up to MaxArgCount arguments. The ValueTypes is a set of TValueType describing all the possible types of values that the function can handle. LookupFunction should return True if the FunctionName is recognized, TCalculator will take care of insuring that the expression has the necessary arguments for the function and that they are all of the required types. Note that ValueTypes applies to ALL of the arguments, that is, you cannot specify that the first argument must be a string and the second a number, but you will see later on how this can be handled but, for now, make ValueTypes all the possible value types for all of the arguments. Its is important that when you override LookupFunction, you call the inherited method and proceed only if the result is False. This will ensure that the built-in operators will work properly. If you do not call the inherited method, you will lose the built in operators, but you can provide their behavior yourself.
For example, the code below recognizes the "average" function:
function TTestCalculator.LookupFunction( const
FunctionName : string; var MinArgCount , MaxArgCount : Integer; var ValueTypes :
TValueTypes ) : Boolean;
begin
Result := inherited LookupFunction( FunctionName , MinArgCount , MaxArgCount ,
ValueTypes );
if not Result then
begin
if EqualStr( FunctionName , 'AVERAGE' ) then
begin
MinArgCount := 3;
MaxArgCount := 3;
ValueTypes := [ vtNumber ];
Result := True;
end;
end;
end;
First, it calls the inherited LookupFunction, to handle any built-in operators and functions.The it uses the utility function EqualStr to see if the FunctionName is "average". EqualStr is simply does a case insensitive comparison and returns True if the strings are equal. It then sets the argument count to 3, which means the function must have exactly 3 arguments. It also sets ValueTypes to [ vtNumber ], which means that all three arguments must be of type number. It sets the Result to True, accepting the function.
Now, you must provide the code to solve the function "average". This is done by overriding the method SolveFunction. This method is always called after lookup function returned True and all of the arguments have been verified. When SolveFunction is called, you are guaranteed that there are exactly as many arguments as you specified in LookupFunction and that they are all of the types specified. SolveFunction looks like this:
function SolveFunction( const FunctionName : string; Arguments : TStrings; var ReturnType : TValueType; var Return : string ) : Boolean; virtual;
The first parameter is the FunctionName, the same used in LookupFunction. Arguments is a string list containing all of the arguments to the function. ReturnType is the return type of the function and Return is the return value of the function. The Boolean Result should be True if the function was solved successfully. You should always call the inherited SolveFunction before doing anything else, if you wish to retain the built-in operators and functions. Here is an implementation of the "average" function described above.
function TTestCalculator.SolveFunction( const FunctionName : string; Arguments : TStrings;
var ReturnType : TValueType; var Return : string ) : Boolean;
begin
Result := inherited SolveFunction( FunctionName , Arguments , ReturnType , Return
);
if not Result then
begin
if EqualStr( FunctionName , 'AVERAGE' ) then
begin
Result := True;
ReturnType := vtNumber;
Return := FloatToStr( ( StrToFloat(
Arguments[ 0 ] ) +
StrToFloat( Arguments[
1 ] ) + StrToFloat( Arguments[ 2 ] ) ) / 3 );
end;
end;
end;
First, it calls the inherited SolveFunction, to retain the
built-in functionality. Then, it checks the FunctioName to see if it is
"average". It sets the ReturnType to vtNumber and does the calculation based on
the Arguments provided. It is just that easy.
Making "average" more robust
As it stands, the average function is not very robust, for example, it does not support identifiers. With the code above, a call to "average( a , 1 , 2 )" will fail, because we specified vtNumber as the only type of argument that "average" can accept. So, we must fix this.
Note: The first version of TCalculator resolved all identifiers before solving the function, which worked very well, because individual functions did not have to worry about supporting identifiers. By the time SolveFunction was called, all the arguments were either vtEmpty, vtNumber or vtString. Then I wanted to implement special functions that worked on identifiers, and not on their values. For example, I wanted to be able to solve the function "Sum( A1:B2)" which actually operates on a range, rather than the values of the identifiers A1 and B2. So, to increase flexibility, I pushed the resolution of identifiers to SolveFunction.
Regardless, back to our case. To add support for identifiers to our "average" function, we must make two small changes. In the LookupFunction, we must return ValueTypes as [ vtIdentifier , vtNumber ] signaling that "average" will accept both. Then, in SolveFunction, we place a call to ResolveArguments as follows:
function TTestCalculator.SolveFunction( const FunctionName
: string; Arguments : TStrings; var ReturnType : TValueType; var Return : string ) :
Boolean;
begin
Result := inherited SolveFunction( FunctionName , Arguments , ReturnType , Return
);
if not Result then
begin
if EqualStr( FunctionName , 'AVERAGE' ) then
begin
Result := True;
ReturnType := vtNumber;
ResolveArguments( Arguments
, [ vtNumber ] );
Return := FloatToStr( ( StrToFloat(
Arguments[ 0 ] ) +
StrToFloat( Arguments[
1 ] ) + StrToFloat( Arguments[ 2 ] ) ) / 3 );
end;
end;
end;
ResolveArguments is a helper function that will traverse the Arguments string list and call ResolveIdentifier for each argument of type vtIdentifier. It also checks that all of the resolved identifiers are of the types in the second parameter. When ResolveArguments is done, all of the arguments will be of a type contained in the set passed to ResolveArguments. If an identifier is not found or if one resolves to an invalid type, then ResolveArguments will raise an exception.
We still have another problem, which is not major but, if solved, would make "average" much more robust. The function does not suport the vtEmpty type. This can be fixed easily with a helper function. First, we have to go back to LookupFucntion and include vtEmpty in the set of supported argument types. Then, we do the same with our call to ResolveArguments, including vtEmpty in the second parameter. Then we simply place a call to the helper function ReplaceEmptysWith as follows:
function TTestCalculator.SolveFunction( const FunctionName
: string; Arguments : TStrings; var ReturnType : TValueType; var Return : string ) :
Boolean;
begin
Result := inherited SolveFunction( FunctionName , Arguments , ReturnType , Return
);
if not Result then
begin
if EqualStr( FunctionName , 'AVERAGE' ) then
begin
Result := True;
ReturnType := vtNumber;
ResolveArguments( Arguments
, [ vtNumber , vtEmpty ] );
ReplaceEmptysWith(
Arguments, '0' );
Return := FloatToStr( ( StrToFloat(
Arguments[ 0 ] ) +
StrToFloat( Arguments[
1 ] ) + StrToFloat( Arguments[ 2 ] ) ) / 3 );
end;
end;
end;
This will ensure that all empty values are replaced with the string '0' in the Arguments list, and so will be treated as such when we solve "average".