Java 应用:自制高精度计算器(1)

一直以来,我的计算器都是 Python 的 REPL(Java8 之后偶尔也用 jjs (Nashorn))。但是这些 REPL 的问题在于,在涉及到小数时,它们使用的是浮点数进行运算,于是不可避免的出现了浮点数精度缺失的问题:

浮点数精度缺失问题

这个问题,忍得太久,今天又遇到了 —— 所以才会有这样一个想法:自己做一个命令行下的计算器,使用高精度数来代替浮点数进行运算,从而解决掉浮点数精度缺失的问题。


要做一个计算器,首先需要能对一个输入的表达式进行解析,获得这个表达式中包含的所有标识(Token):比如 运算符括号 等,当然最好还能够自定义函数,比如 logpowsin 等。(在编译原理中,这个过程叫 词法分析

所以,我们先来定义表示这些 Token 的类。首先定义 运算符括号函数 这些 Token 的接口,就命名为 Token

public interface Token {

    public TokenType getType();

    public String text();

    public boolean isNumber();

    public boolean isOperator();

    public boolean isBracket();

    public boolean isFunction();
    
}

getType() 方法用来返回一个 TokenType 类型,TokenType 是一个枚举,它包括了以下四个种类:

public enum TokenType {

    /**
     * 数
     */
    NUMBER,
    /**
     * 运算符
     */
    OPERATOR,
    /**
     * 括号
     */
    BRACKET,
    /**
     * 函数
     */
    FUNCTION
}

为了避免 Token 的每个实现类都需要实现 Token 的每一个方法,我们先定义一个 AbstractToken 类,对每个 isXXX 方法都进行实现 —— 通过 getType 方法来判断是否是该 Token 对应的 TokenType。然后让 Token 的真正实现类去覆写 getType 方法,并返回其对应的 TokenType。比如对于 ,那么 getType 返回 TokenType.NUMBER,那么在该类上调用 isNumber 方法,便会返回 true,而其他 isXXX 方法都会返回 false

public abstract class AbstractToken implements Token {

    @Override
    public boolean isNumber() {
        return getType() == TokenType.NUMBER;
    }

    @Override
    public boolean isOperator() {
        return getType() == TokenType.OPERATOR;
    }

    @Override
    public boolean isBracket() {
        return getType() == TokenType.BRACKET;
    }

    @Override
    public boolean isFunction() {
        return getType() == TokenType.FUNCTION;
    }

}

然后按照每种 Token 的需求挨个实现 Token 接口。因为 Java 中已经存在了一个 Number 类,而且位于 java.lang 包下,所以为了避免混淆,我将 类的名称定义为 Num

public class Num extends AbstractToken implements Token {

    private final BigDecimal value;

    public Num(BigDecimal value) {
        this.value = value;
    }

    public Num(String value) {
        this.value = new BigDecimal(value);
    }

    public BigDecimal value() {
        return value;
    }

    @Override
    public TokenType getType() {
        return TokenType.NUMBER;
    }

    @Override
    public String text() {
        return String.valueOf(value);
    }

}

因为我们要使用高精度数来代替浮点数,所以 Num 的真正实现,交给了 BigDecimal

运算符 类:

public class Operator extends AbstractToken implements Token {

    private final char value;

    public Operator(char value) {
        if (value == '+' || value == '-' || value == '*' || value == '/') {
            this.value = value;
        } else {
            throw new RuntimeException(String.format("未知的运算符:%c", value));
        }
    }

    /**
     * 获得运算符的优先级
     *
     * @return 运算符的优先级
     */
    public int property() {
        switch (value) {
            case '*':
            case '/':
                return 1;
            default:
                return 0;
        }
    }

    /**
     * 该运算符的优先级是否大于所给的运算符的优先级
     *
     * @param other 所给的运算符
     * @return 如果该运算符的优先级是否大于所给的运算符的优先级,返回 true,否则返回 false
     */
    public boolean isHigherThan(Operator other) {
        return this.property() > other.property();
    }

    @Override
    public TokenType getType() {
        return TokenType.OPERATOR;
    }

    @Override
    public String text() {
        return String.valueOf(value);
    }

}

运算符 应该具有优先级,所以我们定义了 isHigherThan(Operator other) 方法来判断当前运算符对象(this) 的优先级是否大于给定运算符对象(other)。

括号 类:

public class Bracket extends AbstractToken implements Token {

    public static final char CHAR_LEFT_BRACKET = '(';
    public static final char CHAR_RIGHT_BRACKET = ')';

    private final char value;

    public Bracket(char value) {
        if (value == CHAR_LEFT_BRACKET || value == CHAR_RIGHT_BRACKET) {
            this.value = value;
        } else {
            throw new RuntimeException("未知的括号字符:" + value);
        }
    }

    public boolean isLeft() {
        return value == CHAR_LEFT_BRACKET;
    }

    public boolean isRight() {
        return value == CHAR_RIGHT_BRACKET;
    }

    @Override
    public TokenType getType() {
        return TokenType.BRACKET;
    }

    @Override
    public String text() {
        return String.valueOf(value);
    }

}

括号 类定义了 isLeftisRight 方法,分别用来判断该括号是左括号还是右括号。

而函数类的定义比上面这些更加复杂一些,我们稍后再给出。


Token 的类都定义完毕之后,我们便可以开始实现表达式的解析了 —— 即将一个表达式转换为多个 Token。此时,我们需要定义能够匹配各种 Token 的正则表达式。

  1. 我们先来看 对应的正则。 包括整数和小数,整数的正则:\d+\d 表示匹配数字,+ 表示至少需要一位数字;小数的正则:\d*\.\d+\d* 表示小数点前面可以有 0 个或者或多个数字,即 12.3 和 .3 都表示一个小数,而 .3 在一般的编程语言中都默认为 0.3。所以匹配数字的正则为 \d*\.\d+|\d+(请大家思考下为什么要将小数的正则放到前面)。

  2. 然后,操作符 对应的正则。我们的操作符包括了 +-*/,所以对应的正则为 \+|-|\*|/+* 需要转义)。

  3. 括号的正则:\(|\) —— 括号也需要转义。

  4. 最后是 函数 的正则。我们通常使用计算器的函数都是形如 function_name(param1, param2) 这样的形式,所以我们定义函数的正则为 [A-Za-z]+\(.*\) —— .* 表示参数可以有也可以没有,即存在 function_name() 这样的函数。

所以给出匹配 Token 的正则表达式为:(\d*\.\d+|\d+)|(\+|-|\*|/)|(\(|\))|[A-Za-z]+\(.*\) —— 为了解析时将 Token 进行区分,我们将每种 Token 的正则都用括号包括,这样的话每种 Token 在正则做提取时就会作为不同的 group,从而在提取时每种 Token 可以被区分。
因为输入表达式的时候, Token 之间难免会存在空格,所以我们需要加入对空格的处理 —— \s* 表示匹配一个或者多个空格 —— 加入空格处理后的正则表达式变为:\s*((\d*\.\d+|\d+)|(\+|-|\*|/)|(\(|\))|[A-Za-z]+\(.*\))\s*,即 \s*(Token 的正则)\s*


此时我们可以给出 Token函数 类的定义:

public class Function extends AbstractToken implements Token {

    private static final Num[] PARAMS_NONE = new Num[0];

    private final String name;  // 名字
    private final Num[] params; // 参数

    public Function(String content) {
        int indexOfLeftBracket = content.indexOf('(');
        int indexOfRightBracket = content.lastIndexOf(')');

        name = content.substring(0, indexOfLeftBracket);

        String paramsContent = content.substring(
                indexOfLeftBracket + 1, indexOfRightBracket);

        // 提取出各个参数
        String[] paramStrs = paramsContent.split(",");
        if (paramStrs.length == 1 && paramStrs[0].isEmpty()) { // 没有参数
            params = PARAMS_NONE;
            
        } else { // 有参数
            params = new Num[paramStrs.length];

            for (int i = 0; i < params.length; i++) {
                String paramStr = paramStrs[i].trim();
                params[i] = new Num(paramStr);
            }
        }
    }

    /**
     * 获得函数的结果
     *
     * @return 函数的结果
     */
    public Num getResult() {
        Func function = FuncFactory.getFunc(name);

        if (function != null) {
            return function.apply(params);
        }

        throw new RuntimeException("未知的函数:" + name);
    }

    @Override
    public TokenType getType() {
        return TokenType.FUNCTION;
    }

    @Override
    public String text() {
        StringBuilder function = new StringBuilder();

        function.append(name).append('(');
        for (Num param : params) {
            function.append(param.text()).append(", ");
        }
        function.delete(function.length() - 2, function.length());
        function.append(')');

        return function.toString();
    }

}

可以看到 Function 类中定义了 getResult 方法 —— 用来获得函数的结果。该方法首先调用 FuncFactorygetFunc 方法,通过函数名获得一个 Func,然后 Funcapply 方法可以根据参数求得函数的值。具体代码就是我将 Func 定义为接口:

public interface Func {

    public Num apply(Num[] params);
    
}

然后通过 FuncFactorygetFunc 方法返回不同的 Func 的实现:

public class FuncFactory {

    public static Func getFunc(String name) {
        switch (name.toLowerCase()) {
            case "log":
                return new LogFunc();
            case "pow":
                return new PowFunc();
            case "pi":
                return new PIFunc();
        }

        return null;
    }
}

这样一来,每次添加一个 函数,只需要写一个对应的 Func 接口的实现,然后将其名称加入到FuncFactory 中就行 —— 使用的是 简单工厂模式


正则定义完毕之后,便可以使用该正则来解析表达式,我们定义一个表达式类 Expression

public class Expression {

    private static final String REG_EXPR = "\\s*((\\d*\\.\\d+|\\d+)|(\\+|-|\\*|/)|(\\(|\\))|([A-Za-z]+\\(.*\\)))\\s*";
    private static final Pattern PATTERN = Pattern.compile(REG_EXPR);

    private final List<Token> tokens; // 该表达式中的所有 Token

    public Expression(List<Token> tokens) {
        this.tokens = tokens;
    }

    public Expression(String expr) {
        this.tokens = parseTokens(expr);
    }
    
    ...
}

(因为在 Java 中,\ 在字符串中需要用 \\ 表示,所以 REG_EXPR 代表的正则都使用了 \\ 来表示 \

现在,我们需要一个方法,这个方法可以将字符串形式的表达式,解析为多个 Token,我们定义这个方法为 parseTokens

private List<Token> parseTokens(String expr) {
    List<Token> ts = new ArrayList<>();

    Matcher matcher = PATTERN.matcher(expr);
    int start = 0, end = expr.length();

    while (start < end) {
        // 设定正则的查找范围在 [start, end),不包括 end
        matcher.region(start, end);

        // lookingAt() 方法会从 start 位置开始匹配下一个满足正则的子串
        // 我也不知道当年 Java 的开发人员为什么会取 lookingAt 这么鬼畜的名字
        if (matcher.lookingAt()) { //  如果找到了一个匹配正则的子串
            Token token = getToken(matcher);
            ts.add(token);

            start = matcher.end(); // end() 方法会返回上一次匹配的子串的末尾的位置

        } else { // 没有找到匹配正则的子串,说明表达式包含了非正则中定义的文本
            String errorExpr = expr.substring(start);
            throw new RuntimeException("错误的表达式:" + errorExpr);
        }
    }

    return ts;
}

private Token getToken(Matcher matcher) {
    // matcher.group(1) 匹配最外层的括号(matcher.group(0) 是匹配整个正则)
    String m = matcher.group(1);

    if (m != null) { 
        if (matcher.group(2) != null) { // 数
            return new Num(matcher.group(2));

        } else if (matcher.group(3) != null) { // 运算符
            char operatorValue = matcher.group(3).charAt(0);
            return new Operator(operatorValue);

        } else if (matcher.group(4) != null) { // 括号
            char bracketValue = matcher.group(4).charAt(0);
            return new Bracket(bracketValue);

        } else { // 函数
            Function function = new Function(matcher.group(5));
            Num num = function.getResult();

            return num;
        }
    }

    throw new RuntimeException("getToken"); // 正则无误的情况下不会发生
}

parseTokens 方法的过程就是使用 Matcher 类扫描表达式(输入的字符串),每次提取出一个 Token,并加入到集合中,直到扫描完毕。getToken 方法中,对于函数,我们先求出函数的结果(Num),然后便可以直接将其作为一个 Token

我们顺便覆写下 ExpressiontoString 方法,然后对 Expression 写几个示例测试一下。为了方便展示此处直接使用 main 方法,不使用 JUnit 这样的工具:

@Override
public String toString() {
    StringBuilder expr = new StringBuilder();

    for (Token token : tokens) {
        expr.append(token.text()).append(' ');
    }
    expr.deleteCharAt(expr.length() - 1);

    return expr.toString();
}

main 函数:

public static void main(String[] args) throws Exception {
    Expression expr = new Expression("1+2*3");
    System.out.println(expr);

    expr = new Expression("(1+2) * 3");
    System.out.println(expr);

    expr = new Expression("0.65- 0.56");
    System.out.println(expr);

    expr = new Expression("6 # 8");
}

运行结果:

解析表达式的运行结果

可见我们已经能够成功的解析表达式,并将其转化为多个 Token —— 下一篇 文章将讲解如何计算表达式的值(完整的源码在 GitHub)。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容

  • 上一篇 文章讲了如何通过正则来将输入的表达式解析为多个 Token,而这篇文章的核心在于如何对 表达式求值。我们输...
    MiZhou阅读 430评论 0 0
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,494评论 18 139
  • 抱抱宝贝,今天的5分钟 陪着宝贝的时间过得很快,想着宝贝的时间过得很慢,搂着你的时候时间像静止了一样,慢慢的,缓缓...
    握着荆条阅读 247评论 0 0
  • 下着雨的夏夜,安静地卧在床上,听着雨滴一滴一滴落在地上,想象着那透明的水花,紧紧的盖好被子,把久久紧闭的宿舍门打开...
    小风扇之悠悠的转阅读 535评论 0 1
  • 终还自己一个旅程! 虽然我不知道 路上会遇见谁 又会与谁擦肩而过 背上行囊独自行走 至天涯到海角 若是有缘走一段吧...
    向行阅读 833评论 2 1