公式计算(或叫表达式计算)通常应用在低代码、脚本插件等功能中,优点是可以定制软件的业务逻辑,而无需专业的编程人员参与,降低软件的开发成本,满足多样化的需求。目前有一些优秀的公式计算引擎如 Aviotor、Power Fx 等。
一、编译原理复习
公式计算的原理和编程语言本质上是一样的,更准确的说是和解释型语言是一样的,如JavaScript
、Perl
、Python
等,也是一种简单的脚本语言。只不过这种语言会去掉通常编程语言的大部分语法功能,让非专业人员就可以轻松掌握使用。所以开发一个公式计算引擎依然要用编译原理部分知识,在此之前就先复习一下编译原理相关内容。
这部分内容主要源于 Robert Nystrom (目前供职于Google Dart 团队)编写的一部教程 Crafting Interpreters ,非常建议一读,作者就是以代码的形式一步一步讲解展示了整个编程语言编译运行的过程,最后完成了一门新的编程语言,对开发人员来说更容易理解编译原理的理论。本节基本上是对Crafting Interpreters 部分内容的一个简短总结概述。
现代编程语言的编译过程通常分两个部分:前端编译和后端编译,前端编译将源代码编译成中间代码IR
,后端编译将中间代码IR
编译目标平台的机器码,这样区分是可以复用每个部分的功能,比如要新增一门语言的编译器时,可以只开发这门语言的前端编译器部分,编译好的中间代码直接传给已有平台的后端编译器即可,而不用对整个编译过程重新开发,新增一个目标平台的编译器也是同理,只开发目标平台的后端编译器即可。对于公式计算引擎来说,主要就是前端编译部分的开发,前端编译过程大致有以下几个步骤:
- 分词扫描
- 语法解析
- 静态语义分析
- 源代码优化
- 生成语法树
1. 分词扫描
分词扫描也可以叫做词法分析 (Lexical Analysis) ,是将源代码划分成一个一个词牌(Token)的过程:
分词扫描剔除一些无用的字符,保留了一个干净的有意义的词牌序列,如上图代码在分词扫描后生成的词牌序列:
["var", "average", "=", "(", "min", "+", "max", ")", "/", "2", ";"]
复制代码
序列中字符的顺序与其在源代码中从左到右从上至下的阅读方向顺序保持一致。
2. 语法解析
上一步完成分词扫描后,就可以对生成的词牌序列进行语法解析,解析后的结果会将词牌构造成一个解析树 (Parse Tree) 或抽象语法树 (Abstract Syntax Tree) :
3. 静态语义分析
在静态语义分析的阶段,需要确定前面阶段生成的语法树的含义。比如表达式: a+b
,在前面的步骤我们可以区分出 a
, +
, b
, 但是不知道 a
和 b
具体代表什么,是局部变量?全局变量?他们是在哪定义的?等等。
如果是声明静态类型的语言,可以判断变量的类型,从而判断该类型是否可以用在当前表达式中,如果不能,将抛出类型错误。
以上变量声明的定义、类型等信息需要一个存储位置,以便于在语义分析时查找并判断:
- 通常,可以将这些信息作为属性 (Attributes) 存储在语法数的节点上,初始的时候这些属性没有值,在解析 (Parse) 逐步将信息填充到这些属性中。
- 另一方面,可以建立一个符号表 (Symbol Table) 存储变量的定义信息,当表达式用到某个变量时,可以通过查找符号表来确定变量定义的类型和作用域等信息。
4. 源代码优化
如果我们可以将一段代码用另一段相同语义但是更高效的代码替换,这个步骤就是优化。
比如以下源代码:
pennyArea = 3.14159 * (0.75 / 2) * (0.75 / 2);
复制代码
优化后可以用如下代码替换:
pennyArea = 0.4417860938;
复制代码
5. 生成语法树
源代码优化完成后,在重新生成一个新的语法树,目前抽象语法树 (AST) 使用较为广泛。
二、Dart Analyzer 代码分析
Dart 官方的代码分析工具analyzer
提供了完整的词法、语法、语义分析与语法树的生成,所以先分析一下analyzer
的代码实现,相关代码在Dart SDK 项目目录pkg/_fe_analyzer_shared
和 pkg/analyzer
下。
1. 分词扫描
这部分主要看两块地方的代码,一个是词牌 (Token) 的定义,一个是扫描 (Scan) 的实现逻辑,对应的代码文件分别是:
pkg/_fe_analyzer_shared/lib/src/scanner/token.dart
pkg/_fe_analyzer_shared/lib/src/scanner/abstract_scanner.dart
下面对部分核心的代码进行说明,首先看一下Token
的定义:
abstract class Token implements SyntacticEntity {
/**
* Initialize a newly created token to have the given [type] and [offset].
*/
factory Token(TokenType type, int offset, [CommentToken? preceedingComment]) =
SimpleToken;
/**
* Initialize a newly created end-of-file token to have the given [offset].
*/
factory Token.eof(int offset, [CommentToken? precedingComments]) {
Token eof = new SimpleToken(TokenType.EOF, offset, precedingComments);
// EOF points to itself so there's always infinite look-ahead.
eof.previous = eof;
eof.next = eof;
return eof;
}
/**
* The number of characters parsed by this token. */
int get charCount;
/**
* The character offset of the start of this token within the source text.
*/
int get charOffset;
/**
* The character offset of the end of this token within the source text. */
int get charEnd;
@override
int get end;
/**
* The token that corresponds to this token, or `null` if this token is not
* the first of a pair of matching tokens (such as parentheses).
*/
Token? get endGroup => null;
/**
* Return `true` if this token represents an end of file. */
bool get isEof;
@override
int get length;
/**
* Return the lexeme that represents this token.
*
* For [StringToken]s the [lexeme] includes the quotes, explicit escapes, etc.
*/
String get lexeme;
/**
* Return the next token in the token stream.
*/
Token? get next;
/**
* Return the next token in the token stream.
*/
void set next(Token? next);
@override
int get offset;
/**
* Set the offset from the beginning of the file to the first character in * the token to the given [offset].
*/
void set offset(int offset);
/** * Return the previous token in the token stream. */
Token? get previous;
/** * Return the type of the token.
*/
TokenType get type;
...
}
复制代码
上面截取了部分Token
定义的代码,需要关注的属性有:
lexeme
:Token
的字符内容;offset
和charOffset
: 当前Token
在源代码中的位置,offset
字段和charOffset
字段,二者值是一致的,offset
主要是为了实现SyntacticEntity
接口方法;isEof
:是否是结束Token
;previous
:当前Token
的前一个Token
;next
:当前Token
的下一个Token
;
通过next
和 previous
字段可知在 analyzer
中分词后的词牌序列是以链表方式组织起来的,这样一个词牌序列会有一个起始Token
和结束 Token
,通常结束Token
一定是isEof = true
。
下面在看扫描部分的代码实现:
abstract class AbstractScanner implements Scanner {
...
Token tokenize() {
while (!atEndOfFile()) {
int next = advance();
// Scan the header looking for a language version
if (!identical(next, $EOF)) {
Token oldTail = tail;
next = bigHeaderSwitch(next);
if (!identical(next, $EOF) && tail.kind == SCRIPT_TOKEN) {
oldTail = tail;
next = bigHeaderSwitch(next);
}
while (!identical(next, $EOF) && tail == oldTail) {
next = bigHeaderSwitch(next);
}
}
while (!identical(next, $EOF)) {
next = bigSwitch(next);
}
if (atEndOfFile()) {
appendEofToken();
} else {
unexpectedEof();
}
}
// Always pretend that there's a line at the end of the file.
lineStarts.add(stringOffset + 1);
return firstToken();
}
...
int bigSwitch(int next) {
beginToken();
if (identical(next, $SPACE) ||
identical(next, $TAB) ||
identical(next, $LF) ||
identical(next, $CR)) {
appendWhiteSpace(next);
next = advance();
// Sequences of spaces are common, so advance through them fast.
while (identical(next, $SPACE)) {
// We don't invoke [:appendWhiteSpace(next):] here for efficiency,
// assuming that it does not do anything for space characters.
next = advance();
}
return next;
}
int nextLower = next | 0x20;
if ($a <= nextLower && nextLower <= $z) {
if (identical($r, next)) {
return tokenizeRawStringKeywordOrIdentifier(next);
}
return tokenizeKeywordOrIdentifier(next, /* allowDollar = */ true);
}
if (identical(next, $CLOSE_PAREN)) {
return appendEndGroup(TokenType.CLOSE_PAREN, OPEN_PAREN_TOKEN);
}
if (identical(next, $OPEN_PAREN)) {
appendBeginGroup(TokenType.OPEN_PAREN);
return advance();
}
if (identical(next, $SEMICOLON)) {
appendPrecedenceToken(TokenType.SEMICOLON);
// Type parameters and arguments cannot contain semicolon.
discardOpenLt();
return advance();
}
if (identical(next, $PERIOD)) {
return tokenizeDotsOrNumber(next);
}
if (identical(next, $COMMA)) {
appendPrecedenceToken(TokenType.COMMA);
return advance();
}
if (identical(next, $EQ)) {
return tokenizeEquals(next);
}
if (identical(next, $CLOSE_CURLY_BRACKET)) {
return appendEndGroup(
TokenType.CLOSE_CURLY_BRACKET, OPEN_CURLY_BRACKET_TOKEN);
}
if (identical(next, $SLASH)) {
return tokenizeSlashOrComment(next);
}
if (identical(next, $OPEN_CURLY_BRACKET)) {
appendBeginGroup(TokenType.OPEN_CURLY_BRACKET);
return advance();
}
if (identical(next, $DQ) || identical(next, $SQ)) {
return tokenizeString(next, scanOffset, /* raw = */ false);
}
if (identical(next, $_)) {
return tokenizeKeywordOrIdentifier(next, /* allowDollar = */ true);
}
if (identical(next, $COLON)) {
appendPrecedenceToken(TokenType.COLON);
return advance();
}
if (identical(next, $LT)) {
return tokenizeLessThan(next);
}
if (identical(next, $GT)) {
return tokenizeGreaterThan(next);
}
if (identical(next, $BANG)) {
return tokenizeExclamation(next);
}
if (identical(next, $OPEN_SQUARE_BRACKET)) {
return tokenizeOpenSquareBracket(next);
}
if (identical(next, $CLOSE_SQUARE_BRACKET)) {
return appendEndGroup(
TokenType.CLOSE_SQUARE_BRACKET, OPEN_SQUARE_BRACKET_TOKEN);
}
if (identical(next, $AT)) {
return tokenizeAt(next);
}
if (next >= $1 && next <= $9) {
return tokenizeNumber(next);
}
if (identical(next, $AMPERSAND)) {
return tokenizeAmpersand(next);
}
if (identical(next, $0)) {
return tokenizeHexOrNumber(next);
}
if (identical(next, $QUESTION)) {
return tokenizeQuestion(next);
}
if (identical(next, $BAR)) {
return tokenizeBar(next);
}
if (identical(next, $PLUS)) {
return tokenizePlus(next);
}
if (identical(next, $$)) {
return tokenizeKeywordOrIdentifier(next, /* allowDollar = */ true);
}
if (identical(next, $MINUS)) {
return tokenizeMinus(next);
}
if (identical(next, $STAR)) {
return tokenizeMultiply(next);
}
if (identical(next, $CARET)) {
return tokenizeCaret(next);
}
if (identical(next, $TILDE)) {
return tokenizeTilde(next);
}
if (identical(next, $PERCENT)) {
return tokenizePercent(next);
}
if (identical(next, $BACKPING)) {
appendPrecedenceToken(TokenType.BACKPING);
return advance();
}
if (identical(next, $BACKSLASH)) {
appendPrecedenceToken(TokenType.BACKSLASH);
return advance();
}
if (identical(next, $HASH)) {
return tokenizeTag(next);
}
if (next < 0x1f) {
return unexpected(next);
}
next = currentAsUnicode(next);
return unexpected(next);
}
}
复制代码
扫描部分的入口方法是tokenize()
,在这个方法里通过while
循环扫描源代码中每一个字符,然后在bigSwitch()
方法里对字符的Ascii 码进行判断,符合分词规则的字符放入一个Token
中,最后输出一个Token
链。
2. 语法/语义分析,生成语法树
完成词法分析后,接下来对Token
链进行语法/语义的分析,对应的核心代码文件主要是:
pkg/_fe_analyzer_shared/lib/src/parser/parser_impl.dart
pkg/analyzer/lib/src/dart/ast/ast.dart
pkg/analyzer/lib/src/dart/ast/ast_factory.dart
pkg/analyzer/lib/src/fasta/ast_builder.dart
在进行分析的同时就会生成抽象语法树,语法树的节点定义在ast.dart
文件中。分析的入口方法是parser_impl.dart
中的parseUnit()
方法:
/// Parse a compilation unit.
///
/// This method is only invoked from outside the parser. As a result, this
/// method takes the next token to be consumed rather than the last consumed
/// token and returns the token after the last consumed token rather than the
/// last consumed token.
///
/// ```
/// libraryDefinition:
/// scriptTag?
/// libraryName?
/// importOrExport*
/// partDirective*
/// topLevelDefinition*
/// ;
///
/// partDeclaration:
/// partHeader topLevelDefinition*
/// ;
/// ```
Token parseUnit(Token token) {
...
while (!token.next!.isEof) {
final Token start = token.next!;
token = parseTopLevelDeclarationImpl(token, directiveState);
listener.endTopLevelDeclaration(token.next!);
count++;
if (start == token.next!) {
// Recovery:
// If progress has not been made reaching the end of the token stream,
// then report an error and skip the current token.
token = token.next!;
listener.beginMetadataStar(token);
listener.endMetadataStar(/* count = */ 0);
reportRecoverableErrorWithToken(
token, codes.templateExpectedDeclaration);
listener.handleInvalidTopLevelDeclaration(token);
listener.endTopLevelDeclaration(token.next!);
count++;
}
}
...
}
复制代码
方法中通过while
循环遍历Token
链,分析每一个Token
的语法含义,并验证是否合法,若不合法则抛出一个Error
异常,若合法则调用ast_build.dart
中的相关方法来构造AST
,ast_build.dart
中代码定义:
/// A parser listener that builds the analyzer's AST structure.
class AstBuilder extends StackListener {
...
}
复制代码
AstBuilder
继承了StackListener
,StackListener
又继承了Listener
,Listener
中定义了如下类似方法(部分):
/// A parser event listener that does nothing except throw exceptions
/// on parser errors.
///
/// Events are methods that begin with one of: `begin`, `end`, or `handle`.
///
/// Events starting with `begin` and `end` come in pairs. Normally, a
/// `beginFoo` event is followed by an `endFoo` event. There's a few exceptions
/// documented below.
///
/// Events starting with `handle` are used when isn't possible to have a begin
/// event.
class Listener implements UnescapeErrorListener {
...
/// Handle the beginning of a class declaration.
/// [begin] may be the same as [name], or may point to modifiers
/// (or extraneous modifiers in the case of recovery) preceding [name].
///
/// At this point we have parsed the name and type parameter declarations.
void beginClassDeclaration(
Token begin, Token? abstractToken, Token? macroToken, Token name) {}
/// Handle the end of a class declaration. Substructures:
/// - class header
/// - class body
void endClassDeclaration(Token beginToken, Token endToken) {
logEvent("ClassDeclaration");
}
...
void beginDoWhileStatement(Token token) {}
void endDoWhileStatement(
Token doKeyword, Token whileKeyword, Token endToken) {
logEvent("DoWhileStatement");
}
...
/// This method is invoked when parser finishes parsing the corresponding
/// expression of the expression function body.
void handleExpressionFunctionBody(Token arrowToken, Token? endToken) {
logEvent("ExpressionFunctionBody");
}
...
}
复制代码
AstBuilder
对Listener
中的这些方法做了实现,然后通过这些方法构造AST
节点, 构造的过程中使用了栈结构,用来获取当前语法的上下文信息,这个栈定义在StackListener
中:
abstract class StackListener extends Listener {
...
final Stack stack = debugStack ? new DebugStack() : new StackImpl();
void discard(int n) {
for (int i = 0; i < n; i++) {
pop();
}
}
...
void push(Object? node) {
if (node == null) {
internalProblem(
templateInternalProblemUnhandled.withArguments("null", "push"),
/* charOffset = */ -1,
uri);
}
stack.push(node);
}
void pushIfNull(Token? tokenOrNull, NullValue nullValue) {
if (tokenOrNull == null) stack.push(nullValue);
}
Object? peek() => stack.isNotEmpty ? stack.last : null;
Object? pop([NullValue? nullValue]) {
return stack.pop(nullValue);
}
Object? popIfNotNull(Object? value) {
return value == null ? null : pop();
}
...
}
复制代码
可以看下AstBuilder
中的实现例子来看下这个栈的作用:
@override
void endDoWhileStatement(
Token doKeyword, Token whileKeyword, Token semicolon) {
assert(optional('do', doKeyword));
assert(optional('while', whileKeyword));
assert(optional(';', semicolon));
debugEvent("DoWhileStatement");
var condition = pop() as ParenthesizedExpression;
var body = pop() as Statement;
push(ast.doStatement(
doKeyword,
body,
whileKeyword,
condition.leftParenthesis,
condition.expression,
condition.rightParenthesis,
semicolon));
}
复制代码
上面例子表达了Do-While
的语法节点构造:
do{
//body
...
}while(condition?)
复制代码
在构造Do-While
语法AST
节点时,通过出栈两个元素,获取Do-While
的判断条件Condition
和 执行的代码块内容Body
,使用这两个节点的信息才可以完整构造Do-While
的语法节点。当所有节点构造完成后,栈的栈顶节点就是整个AST
树的根节点。
那么接下来如何遍历这些AST
节点?analyzer
使用了访问者模式,访问者的定义(部分)如:
abstract class AstVisitor<R> {
...
R? visitBinaryExpression(BinaryExpression node);
R? visitBlock(Block node);
R? visitBlockFunctionBody(BlockFunctionBody node);
R? visitBreakStatement(BreakStatement node);
R? visitClassDeclaration(ClassDeclaration node);
...
}
复制代码
每一个AST
节点会实现accept(AstVisitor)
方法:
/// A node in the AST structure for a Dart program.
///
/// Clients may not extend, implement or mix-in this class.
abstract class AstNode implements SyntacticEntity {
...
/// Use the given [visitor] to visit this node.
///
/// Return the value returned by the visitor as a result of visiting this
/// node.
E? accept<E>(AstVisitor<E> visitor);
...
}
复制代码
通过实现AstVisitor<R>
的接口方法,新建一个Visitor
实例,作为参数传入根节点的accept(AstVisitor)
方法中,就可以Visitor
中遍历每一个AST
节点并做处理,如下是analyzer
提供的一个默认Visitor
(部分):
/// An AST visitor that will recursively visit all of the nodes in an AST
/// structure (like instances of the class [RecursiveAstVisitor]). In addition,
/// when a node of a specific type is visited not only will the visit method for
/// that specific type of node be invoked, but additional methods for the
/// superclasses of that node will also be invoked. For example, using an
/// instance of this class to visit a [Block] will cause the method [visitBlock]
/// to be invoked but will also cause the methods [visitStatement] and
/// [visitNode] to be subsequently invoked. This allows visitors to be written
/// that visit all statements without needing to override the visit method for
/// each of the specific subclasses of [Statement].
///
/// Subclasses that override a visit method must either invoke the overridden
/// visit method or explicitly invoke the more general visit method. Failure to
/// do so will cause the visit methods for superclasses of the node to not be
/// invoked and will cause the children of the visited node to not be visited.
///
/// Clients may extend this class.
class GeneralizingAstVisitor<R> implements AstVisitor<R> {
/// Initialize a newly created visitor.
const GeneralizingAstVisitor();
...
@override
R? visitBinaryExpression(BinaryExpression node) => visitExpression(node);
@override
R? visitBlock(Block node) => visitStatement(node);
@override
R? visitBlockFunctionBody(BlockFunctionBody node) => visitFunctionBody(node);
@override
R? visitBreakStatement(BreakStatement node) => visitStatement(node);
@override
R? visitClassDeclaration(ClassDeclaration node) =>
visitNamedCompilationUnitMember(node);
...
}
复制代码
三、引擎开发
1. 公式计算功能需求
在开发之前,先明确下公式计算引擎的功能需求:
- 支持常规四则运算,如:
"1 + (2 * 3 - 4) / 5"
; - 支持内置函数,如:
"MAX(1, 2, 3, 4)"
; - 支持变量,如:
"$a$ + $b$ + 3"
; - 支持赋值表达式,如:
"$result$ = 1 + 2 * 3"
;
2. 引擎流程图
流程图中的“词法、语法、语义”分析在前面已经介绍过了,关于“AST Runtime”的内容可以参考我之前的系列文章Flutter 动态化热更新的思考与实践(三)---- 解析AST之Runtime,就是把当时那部分的代码拿了过来。
3. Analyzer 代码剪裁
引擎的核心编译部分主要使用了analyzer
的代码,由于公式计算是比较简单的类编程语言,不需要太多的语法功能,所以对analyzer
的代码做了剪裁提取,只使用到核心关键的部分代码。剪裁的思路如下:
- 提取分词扫描部分的代码,剪裁掉无关的语法关键字的分词扫描,如:
at
,is
$
等,同时添加了支持我们自己的变量声明的Token
:$abc$
; - 提取语法/语义 分析部分代码
Parser
和AST
构造器部分代码AstBuilder
以及AstNode
节点的定义,剪裁掉类class
和 函数function
相关的语法/语义逻辑和响应的AstNode
; - 实现自己的
Visitor
,在遍历AstNode
时构造自定义的AST
数据,用于传入AstRuntime
中执行;
4. Api 封装
定义向外开放的Api :
// 运行公式表达式并返回结果
dynamic fx(String expression) {
}
///
/// 运行包含变量声明($...$)的公式表达式,并返回结果
/// expression: 公式表达式
/// envs: 变量值对象{}
///
dynamic fxWithEnvs(String expression, Map envs) {
}
///
/// 运行包含变量声明($...$)的赋值表达式,赋值的结果更新在`envs`中
/// expression: 赋值公式表达式,如:$a.b$=1+2+3
/// envs: 变量值对象{}
///
dynamic fxAssignment(String expression, Map envs,
{void Function(List<String>)? leftEnvFields}) {
}
复制代码
5. JavaScript 支持
借助Dart
工具dart2js
,可以编译成一个js library
,供前端伙伴使用,都是同一套代码逻辑。在编译js
时,导出Api
需要单独处理一下,为此需要单独写一个针对js
的导出代码jsfx.dart
:
import 'dart:js';
import 'dart:js' as js;
import 'dartfx_main.dart';
import 'util/jsvalue2dart.dart';
void main() {
js.context['jsfx'] = fx;
js.context['jsfxWithEnvs'] = jsfxWithEnvs;
js.context['jsfxAssignment'] = jsfxAssignment;
js.context['jsSetFunctionResolver'] = jsSetFunctionResolver;
}
dynamic jsSetFunctionResolver(JsFunction jsFunction) {
fxSetFunctionResolver((name, arguments) {
return jsFunction.apply([name, ...arguments]);
});
}
dynamic jsfxWithEnvs(String expression, JsObject envs) {
var envsMap = jsValueToDart(envs);
if (envsMap is Map) {
return fxWithEnvs(expression, envsMap);
} else {
return null;
}
}
dynamic jsfxAssignment(String expression, JsObject envs) {
var envsMap = jsValueToDart(envs);
if (envsMap is Map) {
var obj = envs;
var valueKey = "";
var rightValue = fxAssignment(expression, envsMap, leftEnvFields: (fields) {
for (var i = 0; i < fields.length - 1; i++) {
obj = obj[fields[i]];
}
valueKey = fields.last;
});
obj[valueKey] = rightValue;
return rightValue;
}
}
复制代码
在终端执行命令:
dart2js -O2 --omit-implicit-checks -o js/fx.js lib/src/jsfx.dart
复制代码
最后生成的fx.js
就是我们最终要的js library
,可以用一段测试代码验证Api
是否正常导出,正常执行:
require('./d8');
require('./fx');
console.log(jsfx("1+2*3-2"));
复制代码
上面代码中的
./d8
是为支持在终端使用npm
执行js
代码的依赖
四、总结
开发实现这样一个公式计算引擎,也学到了很多东西,尤其是编译原理部分的内容。后面开发这部分没有介绍太多,主要说了下思路和一些说明,感兴趣的话可以到项目主页看代码,会更好的理解。整个项目完成后觉得把一门自然语言编译成计算机可以读懂运行的语言也是蛮有意思的事情,掌握了这个原理可以将任何语言编译成计算机语言,比如之前网上流传的古汉语编程语言。利用这个热乎劲又顺便做了一个json
编辑器项目,可以实时检查json
内容是否符合规范 。最后希望本篇文章能让大家更好的理解编译原理过程,在实际工作项目中能够解决一些问题,并喜欢上编译这个事儿Y(^ ^)。