4

I'm trying to do math from a string.

When I turn a string into a math problem with NSExpression, and then get the result with expressionValue, Swift assumes I want an Integer. Consider these two Playground examples:

let currentCalculation = "10 / 6"
let currentExpression = NSExpression(format: currentCalculation)
print(currentExpression) // 10 / 6
if let result = currentExpression.expressionValue(with: nil, context: nil) as? Double {
    print(result) // 1
}

let anotherCalculation = "10.0 / 6.0"
let anotherExpression = NSExpression(format: anotherCalculation)
print(anotherExpression) // 10 / 6
if let result = anotherExpression.expressionValue(with: nil, context: nil) as? Double {
    print(result) // 1.666666667
}

What should I be doing so that I always get a Double as a result? I don't want to have to parse the string ahead of time.

Pretty interesting that the second example turns "anotherExpression" into Integers, yet still returns a Double as a result.

Beagley
  • 73
  • 1
  • 7

3 Answers3

4

You might be better off using a 3rd party expression parser/evaluator, such as DDMathParser. NSExpression is quite limited, and has no options to force floating point evaluation.

If you want to (or have to) stick to NSExpression: Here is a possible solution to (recursively) replace all constant values in an expression by their floating point value:

extension NSExpression {

    func toFloatingPoint() -> NSExpression {
        switch expressionType {
        case .constantValue:
            if let value = constantValue as? NSNumber {
                return NSExpression(forConstantValue: NSNumber(value: value.doubleValue))
            }
        case .function:
           let newArgs = arguments.map { $0.map { $0.toFloatingPoint() } }
           return NSExpression(forFunction: operand, selectorName: function, arguments: newArgs)
        case .conditional:
           return NSExpression(forConditional: predicate, trueExpression: self.true.toFloatingPoint(), falseExpression: self.false.toFloatingPoint())
        case .unionSet:
            return NSExpression(forUnionSet: left.toFloatingPoint(), with: right.toFloatingPoint())
        case .intersectSet:
            return NSExpression(forIntersectSet: left.toFloatingPoint(), with: right.toFloatingPoint())
        case .minusSet:
            return NSExpression(forMinusSet: left.toFloatingPoint(), with: right.toFloatingPoint())
        case .subquery:
            if let subQuery = collection as? NSExpression {
                return NSExpression(forSubquery: subQuery.toFloatingPoint(), usingIteratorVariable: variable, predicate: predicate)
            }
        case .aggregate:
            if let subExpressions = collection as? [NSExpression] {
                return NSExpression(forAggregate: subExpressions.map { $0.toFloatingPoint() })
            }
        case .anyKey:
            fatalError("anyKey not yet implemented")
        case .block:
            fatalError("block not yet implemented")
        case .evaluatedObject, .variable, .keyPath:
            break // Nothing to do here
        }
        return self
    }
}

Example:

let expression = NSExpression(format: "10/6+3/4")
if let result = expression.toFloatingPoint().expressionValue(with: nil, context: nil) as? Double {
    print("result:", result) // 2.41666666666667
}

This works with "simple" expressions using arithmetic operators and functions and some "advanced" expression types (unions, intersections, ...). The remaining conversions can be added if necessary.

Martin R
  • 529,903
  • 94
  • 1,240
  • 1,382
  • Dammit I was just about to post this exact same thing! BTW you'll probably want to replace `NSExpression(forFunction: function, arguments: newArgs)` with `NSExpression(forFunction: operand, selectorName: function, arguments: newArgs)`. Otherwise you'll break expressions that invoke a selector on a value. – Lily Ballard Oct 03 '17 at 19:47
  • Also I don't see why the block version can't be converted. Just provide your own new block that calls the original and then converts the value to `Double` if possible. – Lily Ballard Oct 03 '17 at 19:49
  • @KevinBallard: What I meant is that a block (which is code, not text) cannot be converted, but you are right (with both comments). – If you have a more complete solution, please post it and I'll delete mine. – Martin R Oct 03 '17 at 19:54
  • My solution was actually going to be less complete; I didn't bother with `.conditional`, only with `.constantValue` and `.function` as those are the two expression types found in `10 / 6`. – Lily Ballard Oct 03 '17 at 20:16
  • It occurs to me that converting integers to doubles really only matters for division, so you might want to restrict the `.function` to only converting arguments if the `function == "divide:by:"`. – Lily Ballard Oct 03 '17 at 21:34
  • Kevin: That might fail for nested expressions, but I‘ll check it tomorrow (too late for today here). – Martin R Oct 03 '17 at 21:38
  • Martin: Ah right, you'd have to change it a bit to have the `.function` case explicitly check each argument to see if it's a `.constantValue`, rather than having it simply recurse into each arg. It also occurs to me that this solution in general won't work for an expression like `count({1,2,3,4,5}) / count({1,2})`. The actual solution there is to wrap all arguments to the `divide:by:` function in another expression that converts integers to blocks. – Lily Ballard Oct 03 '17 at 22:04
  • I went ahead and posted a new answer that handles expressions like `count({1,2,3,4,5}) / count({1,2})`. – Lily Ballard Oct 03 '17 at 22:30
  • Hi, thank you! It sounds like you are saying: NSExpression interprets the string's contents as Int; the output remains an Int when I used .expressionvalue; there is no way to ask for a Double calculation. You recommend: add custom function to NSExpression. That custom function is a switch-case for "expressionType", with which I am unfamiliar, an attribute of any NSExpression. I am unfamiliar with the cases--they appear to represent types of expressions that might be inside my string and transforms them using floating point functions. Which case did your example trigger? ("10/6+3/4") Thanks! – Beagley Oct 04 '17 at 15:21
  • @Beagley: My first suggestion is to use a different expression parser! For example, DDMathParser has no problems with division, and is open source. You can even add additional functions (e.g. trigonometric functions, which NSExpression does not know). – Martin R Oct 04 '17 at 18:25
  • @Beagley: NSExpression(format: ...) parses the string into a tree-like structure, where each node is an NSExpression. My code recursively transforms that tree into a new one where all "constant expression nodes" are replaced by a new node with a "double constant expression". – Martin R Oct 04 '17 at 20:16
  • Thanks Martin R! As I am doing this as a learning exercise, I'm reluctant to utilize a 3rd party library just yet. I figure I should be able to write my own parsing. When the math gets more complex than +-/*(), I may crack. Thanks for explaining the basics behind what NSExpression(format:) is doing. I'm guessing that the case triggered by basic math is ".function"... I don't think I know enough to understand "let newArgs = arguments.map { $0.map { $0.toFloatingPoint() } } return NSExpression(forFunction: operand, selectorName: function, arguments: newArgs)"... but I'm close. :-) – Beagley Oct 05 '17 at 19:40
  • Hi, @MartinR. This looks great. I would like to use it in my (MIT-licensed) project, but AFAIK I can't use it as-is unless I rewrite it, because of licensing (it's CC-BY-SA unless otherwise noted). I know some people use a different license for their SO code—do you do that (or would you be so kind as to give a license for this snippet)? Thanks. – SilverWolf Nov 26 '17 at 23:20
2

Here's a variant of Martin R's great answer that has two important changes:

  • It only converts the arguments to division. Any other functions can still receive integral arguments.
  • It handles expressions like count({1,2,3,4,5}) / count({1,2}) where the arguments to division aren't constant values.

Code:

import Foundation

extension NSExpression {
    func toFloatingPointDivision() -> NSExpression {
        switch expressionType {
        case .function where function == "divide:by:":
            guard let args = arguments else { break }
            let newArgs = args.map({ arg -> NSExpression in
                if arg.expressionType == .constantValue {
                    if let value = arg.constantValue as? Double {
                        return NSExpression(forConstantValue: value)
                    } else {
                        return arg
                    }
                } else {
                    return NSExpression(block: { (object, arguments, context) in
                        // NB: The type of `+[NSExpression expressionForBlock:arguments]` is incorrect.
                        // It claims the arguments is an array of NSExpressions, but it's not, it's
                        // actually an array of the evaluated values. We can work around this by going
                        // through NSArray.
                        guard let arg = (arguments as NSArray).firstObject else { return NSNull() }
                        return (arg as? Double) ?? arg
                    }, arguments: [arg.toFloatingPointDivision()])
                }
            })
            return NSExpression(forFunction: operand, selectorName: function, arguments: newArgs)
        case .function:
            guard let args = arguments else { break }
            let newArgs = args.map({ $0.toFloatingPointDivision() })
            return NSExpression(forFunction: operand, selectorName: function, arguments: newArgs)
        case .conditional:
            return NSExpression(forConditional: predicate,
                                trueExpression: self.true.toFloatingPointDivision(),
                                falseExpression: self.false.toFloatingPointDivision())
        case .unionSet:
            return NSExpression(forUnionSet: left.toFloatingPointDivision(), with: right.toFloatingPointDivision())
        case .intersectSet:
            return NSExpression(forIntersectSet: left.toFloatingPointDivision(), with: right.toFloatingPointDivision())
        case .minusSet:
            return NSExpression(forMinusSet: left.toFloatingPointDivision(), with: right.toFloatingPointDivision())
        case .subquery:
            if let subQuery = collection as? NSExpression {
                return NSExpression(forSubquery: subQuery.toFloatingPointDivision(), usingIteratorVariable: variable, predicate: predicate)
            }
        case .aggregate:
            if let subExpressions = collection as? [NSExpression] {
                return NSExpression(forAggregate: subExpressions.map({ $0.toFloatingPointDivision() }))
            }
        case .block:
            guard let args = arguments else { break }
            let newArgs = args.map({ $0.toFloatingPointDivision() })
            return NSExpression(block: expressionBlock, arguments: newArgs)
        case .constantValue, .anyKey:
        break // Nothing to do here
        case .evaluatedObject, .variable, .keyPath:
            // FIXME: These should probably be wrapped in blocks like the one
            // used in the `.function` case.
            break
        }
        return self
    }
}
Lily Ballard
  • 182,031
  • 33
  • 381
  • 347
  • No, only converting the arguments of the division does not work. Try it with `"10/6+3/4"`, it will evaluate to `1+0=1` instead of `2.41666666666667`. Your code "sees" only the addition and then does not descend into its operands. – Martin R Oct 04 '17 at 04:44
  • Oops you're right, I didn't recurse into the arguments of other functions. I still stand by my claim that we should only actually convert the arguments of division into doubles, but of course we still need to check other functions to find nested divisions. – Lily Ballard Oct 04 '17 at 05:34
  • @MartinR I just updated my code to handle other functions. I'm not at a Mac right now so I can't actually test it though. – Lily Ballard Oct 04 '17 at 05:37
  • There is still a problem with your approach. `"(1/2)/(4/3)"` evaluates to 0.0 instead of 0.375. – Martin R Oct 04 '17 at 06:52
  • Thanks so much for your help! I am reluctant to use your/Martin R's suggestions until I know enough code to understand them. (i.e. no cheating.) I'm not far enough along to walk through the particular cases and understand what is happening. In the meantime, I will work on the other side of the problem-- (Every time the user types, I can append the correct "version" of what they typed to both a String and an NSExpression, interpreting the input as it comes in to make certain that numbers become Doubles, etc. I'll use the String to display and the NSExpression for the calculation. – Beagley Oct 04 '17 at 15:34
  • @MartinR Fixed. I was missing a single call to `toFloatingPointDivision()` – Lily Ballard Oct 04 '17 at 20:11
0

Just use RegEx to convert all values to floats. Example code below:

(Note: If you are passing in variables via the expressionValueWithObject: argument, make sure those are all non-integer as well.)

NSString *equation = @"1/2";//your equation here

/*Convert all numbers to floats so integer-arithmetic doesn't occur*/ {
    
    NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"[0-9.]+" options:NSRegularExpressionCaseInsensitive error:NULL];
    
    NSArray *matches = [regex matchesInString:equation options:0 range:NSMakeRange(0, equation.length)] ;
    
    int integerConversions = 0;
    for (NSTextCheckingResult *match in matches) {
        
        NSRange originalRange = match.range;
        NSRange adjustedRange = NSMakeRange(originalRange.location+(integerConversions*@".0".length), originalRange.length);
        NSString *value = [equation substringWithRange:adjustedRange];
        
        if ([value containsString:@"."]) {
            continue;
        } else {
            equation = [equation stringByReplacingCharactersInRange:adjustedRange withString:[NSString stringWithFormat:@"%@.0", value];
            integerConversions++;
        }
            
    }
        
}

I wrote this in objective-c but it works converted to swift as well.

Albert Renshaw
  • 17,282
  • 18
  • 107
  • 195