Kaleidoscope 系列第二章:实现解析器和 AST

本贴最后更新于 1906 天前,其中的信息可能已经时移俗易

原文链接: Kaleidoscope 系列第二章:实现解析器和 AST

本文是使用 LLVM 开发新语言 Kaleidoscope 教程系列第二章,主要实现 Kaleidoscope 语言的语法解析并生成 AST 的功能。

第二章简介

欢迎来到“使用 LLVM 开发新语言 Kaleidoscope 教程”教程的第二章。本章向我们展示如何使用第一章中构建的词法分析器为我们的 Kaleidoscope 语言构建完整的解析器。有了解析器后,我们将定义并构建一个抽象语法树(AST)。

我们将构建的解析器使用递归下降解析运算符优先解析的组合来解析 Kaleidoscope 语言(后者用于二进制表达式,前者用于其他所有内容)。在进行解析之前,让我们先讨论一下解析器的输出:抽象语法树。

抽象语法树 (AST)

一段程序的抽象语法树很容易在接下来的阶段编译器(比如:代码生成阶段)翻译成机器码。我们通常喜欢用一种对象来构建语言,毫无疑问,抽象语法树是最贴近我们要求的模型。我们将从表达式开始:

/// ExprAST - Base class for all expression nodes.
class ExprAST {
public:
  virtual ~ExprAST() {}
};

/// NumberExprAST - Expression class for numeric literals like "1.0".
class NumberExprAST : public ExprAST {
  double Val;

public:
  NumberExprAST(double Val) : Val(Val) {}
};

上面的代码显示了 ExprAST 基类的定义和一个用于数字文字的子类的定义。关于此代码,需要注意的重要一点是 NumberExprAST 类将文字的数字值捕获为实例变量。这使编译器的后续阶段可以知道所存储的数值是什么。

现在,我们仅创建 AST,因此在它们上没有有用的方法。例如,添加一个虚拟方法来方便地打印代码。以下是 Kaleidoscope 中其他的 AST 节点的定义:

/// VariableExprAST - Expression class for referencing a variable, like "a".
class VariableExprAST : public ExprAST {
  std::string Name;

public:
  VariableExprAST(const std::string &Name) : Name(Name) {}
};

/// BinaryExprAST - Expression class for a binary operator.
class BinaryExprAST : public ExprAST {
  char Op;
  std::unique_ptr<ExprAST> LHS, RHS;

public:
  BinaryExprAST(char op, std::unique_ptr<ExprAST> LHS,
                std::unique_ptr<ExprAST> RHS)
    : Op(op), LHS(std::move(LHS)), RHS(std::move(RHS)) {}
};

/// CallExprAST - Expression class for function calls.
class CallExprAST : public ExprAST {
  std::string Callee;
  std::vector<std::unique_ptr<ExprAST>> Args;

public:
  CallExprAST(const std::string &Callee,
              std::vector<std::unique_ptr<ExprAST>> Args)
    : Callee(Callee), Args(std::move(Args)) {}
};

以上代码要做的事情非常简单:变量节点捕获变量名,二元运算符节点捕获其操作码(例如'+'),函数调用节点捕获函数名以及任何参数表达式的列表。我们的 AST 优势是它捕获了语言功能,而没有关注语言的具体语法细节。请注意,这里没有关于二元运算符,词法结构等的优先级的讨论。

对于我们的基本语言,这些都是我们将定义的所有表达节点。因为它没有条件控制流,所以它不是图灵完备的。我们将在以后的文章中解决。我们现在需要做两件事情,一件是实现在 Kaleidoscope 中调用函数,另一件是记录函数体的本身。

解析器基础

现在我们有了 AST,我们需要定义解析器代码来解析它。这里的想法是我们想要将类似 x + y(由词法分析器作为三个标记返回)的内容解析为可以通过如下调用生成 AST:

auto LHS = std::make_unique<VariableExprAST>("x");
auto RHS = std::make_unique<VariableExprAST>("y");
auto Result = std::make_unique<BinaryExprAST>('+', std::move(LHS),
                                              std::move(RHS));

为此,我们将从定义一些基本的辅助程序开始:

/// CurTok/getNextToken - Provide a simple token buffer.  CurTok is the current
/// token the parser is looking at.  getNextToken reads another token from the
/// lexer and updates CurTok with its results.
static int CurTok;
static int getNextToken() {
  return CurTok = gettok();
}

这在词法分析器周围实现了一个简单的 token 缓冲区。这使我们可以预测词法分析器将返回什么。解析器中的每个函数将假定 CurTok 是需要解析的当前 token。

/// LogError* - These are little helper functions for error handling.
std::unique_ptr<ExprAST> LogError(const char *Str) {
  fprintf(stderr, "LogError: %s\n", Str);
  return nullptr;
}
std::unique_ptr<PrototypeAST> LogErrorP(const char *Str) {
  LogError(Str);
  return nullptr;
}

这些 LogError 部分是我们的解析器用来处理错误的简单程序。我们的解析器中的错误恢复并不是最好的方法,并且不是对用户友好的方法,但是对于我们的教程而言,这已经足够了。这些错误处理例子使我们处理具有各种返回类型的例程中的错误更加容易:它们始终返回 null。

使用这些基本的辅助函数,我们便可以实现语法的第一部分:数字。

解析基本表达式

我们从数字开始,因为它们是最容易处理的。对于语法中的每个产生式,我们将定义一个解析该产生式的函数。对于数字,我们有:

/// numberexpr ::= number
static std::unique_ptr<ExprAST> ParseNumberExpr() {
  auto Result = llvm::make_unique<NumberExprAST>(NumVal);
  getNextToken(); // consume the number
  return std::move(Result);
}

此例程非常简单:它期望在当前 token 是 tok_number token 时被调用。它采用当前数字值,创建一个 NumberExprAST 节点,并将词法分析器移至下一个标记,最后返回。

这有一些有趣的方面。最重要的是,该例程将删去与该生成物相对应的所有 token,并返回准备好下一个 token(不属于语法生成的一部分)的词法分析器缓冲区。这是用于递归下降解析器的相当标准的方法。举一个更好的例子,括号运算符的定义如下:

/// parenexpr ::= '(' expression ')'
static std::unique_ptr<ExprAST> ParseParenExpr() {
  getNextToken(); // eat (.
  auto V = ParseExpression();
  if (!V)
    return nullptr;

  if (CurTok != ')')
    return LogError("expected ')'");
  getNextToken(); // eat ).
  return V;
}

此函数说明了有关解析器的几个有意思的地方:

1)它显示了我们如何使用 LogError 例程。调用此函数时,该函数期望当前标记是一个 ( 标记,但是在解析该子表达式之后,可能没有 ) 等待。例如,如果用户键入 (4x 而不是 (4),则解析器将发出错误。因为可能发生错误,所以解析器需要一种指示错误发生的方法:在我们的解析器中,我们返回错误时为 null。

2)该函数的另一个有意思的方面是它通过调用使用递归 ParseExpression(我们很快就会看到 ParseExpression 可以调用 ParseParenExpr )。它之所以强大是因为它使我们能够处理递归语法,并使每个产生式都非常简单。请注意,括号不会导致 AST 节点本身的构造。虽然我们可以这样做,但是括号最重要的作用是引导解析器并提供分组。一旦解析器构造了 AST,就不需要括号了。

下一个简单的过程是用于处理变量引用和函数调用:

/// identifierexpr
///   ::= identifier
///   ::= identifier '(' expression* ')'
static std::unique_ptr<ExprAST> ParseIdentifierExpr() {
  std::string IdName = IdentifierStr;

  getNextToken();  // eat identifier.

  if (CurTok != '(') // Simple variable ref.
    return llvm::make_unique<VariableExprAST>(IdName);

  // Call.
  getNextToken();  // eat (
  std::vector<std::unique_ptr<ExprAST>> Args;
  if (CurTok != ')') {
    while (1) {
      if (auto Arg = ParseExpression())
        Args.push_back(std::move(Arg));
      else
        return nullptr;

      if (CurTok == ')')
        break;

      if (CurTok != ',')
        return LogError("Expected ')' or ',' in argument list");
      getNextToken();
    }
  }

  // Eat the ')'.
  getNextToken();

  return llvm::make_unique<CallExprAST>(IdName, std::move(Args));
}

该例程遵循与其他例程相同的样式。(如果当前 token 是 tok_identifier 令牌,则期望被调用)。它还具有递归和错误处理功能。一个有趣的方面是,它使用 超前 方法判断来确定当前标识符是独立变量引用还是函数调用表达式。它通过检查标识符后的 token 是否为 ( token,并根据情况构造一个 VariableExprASTCallExprAST 节点来处理此问题。

现在,我们已经有了所有简单的表达式解析逻辑,我们可以定义一个辅助函数,将其封装以便调用。我们将此类表达式称为“基本”表达式,其原因在本教程的后面部分将变得更加清楚。为了解析任意表达式,我们需要确定它是哪种表达式:

/// primary
///   ::= identifierexpr
///   ::= numberexpr
///   ::= parenexpr
static std::unique_ptr<ExprAST> ParsePrimary() {
  switch (CurTok) {
  default:
    return LogError("unknown token when expecting an expression");
  case tok_identifier:
    return ParseIdentifierExpr();
  case tok_number:
    return ParseNumberExpr();
  case '(':
    return ParseParenExpr();
  }
}

通过基本表达式解析器,我们可以明白为什么我们要使用 CurTok 了,这里用了前置判断来选择并调用解析器。

现在已经处理了基本表达式,我们需要处理二进制表达式,它们有点复杂。

解析二元表达式

二进制表达式通常很难确定其实际意义,因此很难解析。例如,当给定字符串 x + y * z 时,解析器可以选择将其解析为 (x + y)* zx + (y * z) 。使用数学上的通用定义,我们希望是按照后面这种进行解析,因为 * (乘法)的 优先级 高于 +(加法)的 优先级

有很多方法可以解决此问题,但是一种优雅而有效的方法是使用 Operator-Precedence Parsing。此解析技术使用二元运算符的优先级来选择运算顺序。首先,我们需要一个优先级表:

/// BinopPrecedence - This holds the precedence for each binary operator that is
/// defined.
static std::map<char, int> BinopPrecedence;

/// GetTokPrecedence - Get the precedence of the pending binary operator token.
static int GetTokPrecedence() {
  if (!isascii(CurTok))
    return -1;

  // Make sure it's a declared binop.
  int TokPrec = BinopPrecedence[CurTok];
  if (TokPrec <= 0) return -1;
  return TokPrec;
}

int main() {
  // Install standard binary operators.
  // 1 is lowest precedence.
  BinopPrecedence['<'] = 10;
  BinopPrecedence['+'] = 20;
  BinopPrecedence['-'] = 20;
  BinopPrecedence['*'] = 40;  // highest.
  ...
}

对于 Kaleidoscope 的基本形式,我们将仅支持 4 个元运算符(当然我们可以随意扩展)。以上 GetTokPrecedence 函数返回当前 token 的优先级;如果 token 不是元运算符,则返回-1。有了映射可以轻松添加新的运算符,并且可以清楚地表明该算法不依赖于所涉及的特定运算符,消除映射并在 GetTokPrecedence 函数中进行比较将很容易。(或者只使用固定大小的数组)。

有了上面定义的帮助程序,我们现在就可以开始分析二元表达式了。运算符优先级解析的基本思想是将具有潜在歧义的二元运算符的表达式分解为多个部分。考虑例如表达式 a + b + (c + d) * e * f + g。运算符优先级解析将其视为由二元运算符分隔的主表达式流。这样,它将首先解析前导主表达式 a,然后将看到对 [+, b] [+, (c + d) ] [, e] [, f] 和 [+, g ]。请注意,因为括号是基础表达式,所以二元表达式解析器根本不需要担心像(c + d)这样的嵌套子表达式。

首先,一个表达式是一个主表达式,其后可能是[binop,primaryexpr]对的序列:

/// expression
///   ::= primary binoprhs
///
static std::unique_ptr<ExprAST> ParseExpression() {
  auto LHS = ParsePrimary();
  if (!LHS)
    return nullptr;

  return ParseBinOpRHS(0, std::move(LHS));
}

ParseBinOpRHS 是为我们解析配对序列的函数。它具有一个优先级和一个指向到目前为止已解析的部分的表达式的指针。请注意,x 是一个完全有效的表达式:因此,binoprhs 允许为空,在这种情况下,它将返回传递给它的表达式。在上面的示例中,代码将 a 的表达式传递到其中 ParseBinOpRHS,当前标记为 +

传递的优先级值 ParseBinOpRHS 指示允许该函数使用的_最小运算符优先级_。例如,如果当前对流为[+,x]并 ParseBinOpRHS 以 40 的优先级传递,则它将不删去任何 token(因为“ +”的优先级仅为 20)。考虑到这一点,ParseBinOpRHS 从以下内容开始:

/// binoprhs
///   ::= ('+' primary)*
static std::unique_ptr<ExprAST> ParseBinOpRHS(int ExprPrec,
                                              std::unique_ptr<ExprAST> LHS) {
  // If this is a binop, find its precedence.
  while (1) {
    int TokPrec = GetTokPrecedence();

    // If this is a binop that binds at least as tightly as the current binop,
    // consume it, otherwise we are done.
    if (TokPrec < ExprPrec)
      return LHS;

此代码获取当前 token 的优先级,并与传入的优先级进行比较。因为我们将无效 token 定义为优先级为-1,它比任何一个运算符的优先级都小,我们可以借助它来获知二元表达式已经结束。若当前的 token 是运算符,我们继续:

// Okay, we know this is a binop.
int BinOp = CurTok;
getNextToken();  // eat binop

// Parse the primary expression after the binary operator.
auto RHS = ParsePrimary();
if (!RHS)
  return nullptr;

这样,此代码将删除(先记住)二元运算符,然后解析随后的主表达式。这样就构成了整个对,对于正在运行的示例,第一对是[+,b]。

现在,我们解析了表达式的左侧和一对 RHS 序列,现在我们必须确定表达式关联的方式。特别是,我们可以设置为 (a + b) binop unparseda + (b binop unparsed) 。为了确定这一点,我们先看 binop 以确定其优先级,并将其与之前 binop 的优先级(在本例中为 +)进行比较:

// If BinOp binds less tightly with RHS than the operator after RHS, let
// the pending operator take RHS as its LHS.
int NextPrec = GetTokPrecedence();
if (TokPrec < NextPrec) {

如果 RHS 右侧的 binop 的优先级低于或等于当前运算符的优先级,则我们知道括号关联为 (a + b) binop… 。在我们的示例中,当前运算符为 +,下一个运算符为 +,我们知道它们具有相同的优先级。在这种情况下,我们将为 a + b 创建 AST 节点,然后继续解析:

      ... if body omitted ...
    }

    // Merge LHS/RHS.
    LHS = std::make_unique<BinaryExprAST>(BinOp, std::move(LHS),
                                           std::move(RHS));
  }  // loop around to the top of the while loop.
}

在上面的示例中,这会将 a + b + 变成 (a + b) 并执行循环的下一个迭代,并以 + 作为当前标记。上面的代码记录下来。接下来解析 (c + d) 作为基础表达式,这使得当前对等于[+, (c + d) ]。然后,它将使用 * 作为主要对象右侧的 binop 评估上面的 if 条件。在这种情况下,* 的优先级高于 + 的优先级,因此将输入 if 条件。

这里剩下的关键问题是“如果条件如何完全解析右侧”?特别是,要为我们的示例正确构建 AST,需要将所有 (c + d) * e * f 作为 RHS 表达变量。做到这一点的代码非常简单(上面两个块中的代码重复了上下文):

    // If BinOp binds less tightly with RHS than the operator after RHS, let
    // the pending operator take RHS as its LHS.
    int NextPrec = GetTokPrecedence();
    if (TokPrec < NextPrec) {
      RHS = ParseBinOpRHS(TokPrec+1, std::move(RHS));
      if (!RHS)
        return nullptr;
    }
    // Merge LHS/RHS.
    LHS = std::make_unique<BinaryExprAST>(BinOp, std::move(LHS),
                                           std::move(RHS));
  }  // loop around to the top of the while loop.
}

至此,我们知道主 RHS 的二进制运算符的优先级高于我们当前正在解析的 binop。因此,我们知道运算符优先级均高于 + 的任何对序列都应一起解析,并作为 RHS 返回。为此,我们递归调用 ParseBinOpRHS 指定 TokPrec + 1 的函数作为继续运行所需的最低优先级。在上面的示例中,这将导致它返回 (c + d) * e * f 的 AST 节点作为 RHS,然后将其设置为 + 表达式的 RHS。

最后,在 while 循环的下一次迭代中,+ g 段被解析并添加到 AST 中。有了这段代码(14 行平凡的代码),我们就以一种非常优雅的方式正确地处理了完全通用的二进制表达式解析。这是这段代码的精妙之处。我们建议再通过一些复杂的例子来贯穿它,以了解其工作原理。

以上结束了对二元表达式的处理。此时,我们可以将解析器指向任意 token 流,并根据该 token 流构建一个表达式,并在不属于该表达式的第一个 token 处停止。接下来,我们需要处理函数定义等。

解析其他部分

接下来缺少的是函数原型的处理。在 Kaleidoscope 中,这些用于“外部”函数声明以及函数主体定义。执行此操作的代码简单明了,而且不是很有趣(一旦我们保留了表达式,就可以直接处理了):

/// prototype
///   ::= id '(' id* ')'
static std::unique_ptr<PrototypeAST> ParsePrototype() {
  if (CurTok != tok_identifier)
    return LogErrorP("Expected function name in prototype");

  std::string FnName = IdentifierStr;
  getNextToken();

  if (CurTok != '(')
    return LogErrorP("Expected '(' in prototype");

  // Read the list of argument names.
  std::vector<std::string> ArgNames;
  while (getNextToken() == tok_identifier)
    ArgNames.push_back(IdentifierStr);
  if (CurTok != ')')
    return LogErrorP("Expected ')' in prototype");

  // success.
  getNextToken();  // eat ')'.

  return std::make_unique<PrototypeAST>(FnName, std::move(ArgNames));
}

鉴于此,函数定义非常简单,只需一个原型以及一个用于实现主体的表达式即可:

驱动代码

该驱动程序仅通过顶级调度循环调用所有解析块。这里没有什么注意的,所以我只包括顶级循环。请参阅以下第二章完整代码清单的“top-level expressions”部分中的完整代码。

小结

通过不到 400 行的注释代码(240 行非注释,非空白代码),我们完全定义了最小语言,包括词法分析器,解析器和 AST 构建器。完成此操作后,可执行文件将验证 Kaleidoscope 代码,并告诉我们其语法是否无效。例如,这是一个交互示例:

$ ./a.out
ready> def foo(x y) x+foo(y, 4.0);
Parsed a function definition.
ready> def foo(x y) x+y y;
Parsed a function definition.
Parsed a top-level expr
ready> def foo(x y) x+y );
Parsed a function definition.
Error: unknown token when expecting an expression
ready> extern sin(a);
ready> Parsed an extern
ready> ^D
$

这里有很多扩展空间。我们可以定义新的 AST 节点,以多种方式扩展语言,等等。在下一部分: 第三章生成 LLVM 中间代码 IR 中,我们将描述如何从 AST 生成 LLVM 中间表示(IR)。

完整代码清单

特别注意: 官方给的例子有些问题,运行出错了,请参考以下运行参数和程序进行编译和运行并演示。

这是我们运行示例的完整代码清单。因为这使用了 LLVM 库,所以我们需要将它们链接起来。为此,我们使用 llvm-config 工具通知 makefile /命令行有关要使用哪些选项的信息:

# Compile
clang++ -g -O3 chapter2-Implementing-a-Parser-and-AST.cpp `llvm-config --cxxflags --system-libs --libs`
# Run
./a.out

以下是本章节代码:

chapter2-Implementing-a-Parser-and-AST.cpp


参考: Kaleidoscope: Implementing a Parser and AST

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...

推荐标签 标签

  • ReactiveX

    ReactiveX 是一个专注于异步编程与控制可观察数据(或者事件)流的 API。它组合了观察者模式,迭代器模式和函数式编程的优秀思想。

    1 引用 • 2 回帖 • 161 关注
  • 微服务

    微服务架构是一种架构模式,它提倡将单一应用划分成一组小的服务。服务之间互相协调,互相配合,为用户提供最终价值。每个服务运行在独立的进程中。服务于服务之间才用轻量级的通信机制互相沟通。每个服务都围绕着具体业务构建,能够被独立的部署。

    96 引用 • 155 回帖 • 1 关注
  • 前端

    前端技术一般分为前端设计和前端开发,前端设计可以理解为网站的视觉设计,前端开发则是网站的前台代码实现,包括 HTML、CSS 以及 JavaScript 等。

    247 引用 • 1348 回帖
  • CongSec

    本标签主要用于分享网络空间安全专业的学习笔记

    1 引用 • 1 回帖 • 16 关注
  • 知乎

    知乎是网络问答社区,连接各行各业的用户。用户分享着彼此的知识、经验和见解,为中文互联网源源不断地提供多种多样的信息。

    10 引用 • 66 回帖 • 1 关注
  • WebSocket

    WebSocket 是 HTML5 中定义的一种新协议,它实现了浏览器与服务器之间的全双工通信(full-duplex)。

    48 引用 • 206 回帖 • 319 关注
  • 音乐

    你听到信仰的声音了么?

    61 引用 • 511 回帖
  • JRebel

    JRebel 是一款 Java 虚拟机插件,它使得 Java 程序员能在不进行重部署的情况下,即时看到代码的改变对一个应用程序带来的影响。

    26 引用 • 78 回帖 • 672 关注
  • 持续集成

    持续集成(Continuous Integration)是一种软件开发实践,即团队开发成员经常集成他们的工作,通过每个成员每天至少集成一次,也就意味着每天可能会发生多次集成。每次集成都通过自动化的构建(包括编译,发布,自动化测试)来验证,从而尽早地发现集成错误。

    15 引用 • 7 回帖
  • NGINX

    NGINX 是一个高性能的 HTTP 和反向代理服务器,也是一个 IMAP/POP3/SMTP 代理服务器。 NGINX 是由 Igor Sysoev 为俄罗斯访问量第二的 Rambler.ru 站点开发的,第一个公开版本 0.1.0 发布于 2004 年 10 月 4 日。

    313 引用 • 547 回帖
  • Maven

    Maven 是基于项目对象模型(POM)、通过一小段描述信息来管理项目的构建、报告和文档的软件项目管理工具。

    186 引用 • 318 回帖 • 281 关注
  • Spark

    Spark 是 UC Berkeley AMP lab 所开源的类 Hadoop MapReduce 的通用并行框架。Spark 拥有 Hadoop MapReduce 所具有的优点;但不同于 MapReduce 的是 Job 中间输出结果可以保存在内存中,从而不再需要读写 HDFS,因此 Spark 能更好地适用于数据挖掘与机器学习等需要迭代的 MapReduce 的算法。

    74 引用 • 46 回帖 • 559 关注
  • ZooKeeper

    ZooKeeper 是一个分布式的,开放源码的分布式应用程序协调服务,是 Google 的 Chubby 一个开源的实现,是 Hadoop 和 HBase 的重要组件。它是一个为分布式应用提供一致性服务的软件,提供的功能包括:配置维护、域名服务、分布式同步、组服务等。

    59 引用 • 29 回帖 • 14 关注
  • 资讯

    资讯是用户因为及时地获得它并利用它而能够在相对短的时间内给自己带来价值的信息,资讯有时效性和地域性。

    55 引用 • 85 回帖 • 1 关注
  • 正则表达式

    正则表达式(Regular Expression)使用单个字符串来描述、匹配一系列遵循某个句法规则的字符串。

    31 引用 • 94 回帖 • 2 关注
  • JSON

    JSON (JavaScript Object Notation)是一种轻量级的数据交换格式。易于人类阅读和编写。同时也易于机器解析和生成。

    52 引用 • 190 回帖 • 1 关注
  • 人工智能

    人工智能(Artificial Intelligence)是研究、开发用于模拟、延伸和扩展人的智能的理论、方法、技术及应用系统的一门技术科学。

    135 引用 • 190 回帖
  • 安装

    你若安好,便是晴天。

    132 引用 • 1184 回帖 • 3 关注
  • React

    React 是 Facebook 开源的一个用于构建 UI 的 JavaScript 库。

    192 引用 • 291 回帖 • 370 关注
  • InfluxDB

    InfluxDB 是一个开源的没有外部依赖的时间序列数据库。适用于记录度量,事件及实时分析。

    2 引用 • 76 关注
  • 星云链

    星云链是一个开源公链,业内简单的将其称为区块链上的谷歌。其实它不仅仅是区块链搜索引擎,一个公链的所有功能,它基本都有,比如你可以用它来开发部署你的去中心化的 APP,你可以在上面编写智能合约,发送交易等等。3 分钟快速接入星云链 (NAS) 测试网

    3 引用 • 16 回帖 • 6 关注
  • TGIF

    Thank God It's Friday! 感谢老天,总算到星期五啦!

    288 引用 • 4485 回帖 • 664 关注
  • 房星科技

    房星网,我们不和没有钱的程序员谈理想,我们要让程序员又有理想又有钱。我们有雄厚的房地产行业线下资源,遍布昆明全城的 100 家门店、四千地产经纪人是我们坚实的后盾。

    6 引用 • 141 回帖 • 584 关注
  • Ant-Design

    Ant Design 是服务于企业级产品的设计体系,基于确定和自然的设计价值观上的模块化解决方案,让设计者和开发者专注于更好的用户体验。

    17 引用 • 23 回帖 • 4 关注
  • Swift

    Swift 是苹果于 2014 年 WWDC(苹果开发者大会)发布的开发语言,可与 Objective-C 共同运行于 Mac OS 和 iOS 平台,用于搭建基于苹果平台的应用程序。

    36 引用 • 37 回帖 • 535 关注
  • Openfire

    Openfire 是开源的、基于可拓展通讯和表示协议 (XMPP)、采用 Java 编程语言开发的实时协作服务器。Openfire 的效率很高,单台服务器可支持上万并发用户。

    6 引用 • 7 回帖 • 101 关注
  • 爬虫

    网络爬虫(Spider、Crawler),是一种按照一定的规则,自动地抓取万维网信息的程序。

    106 引用 • 275 回帖 • 1 关注