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

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

原文链接: 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

相关帖子

欢迎来到这里!

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

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

推荐标签 标签

  • WebComponents

    Web Components 是 W3C 定义的标准,它给了前端开发者扩展浏览器标签的能力,可以方便地定制可复用组件,更好的进行模块化开发,解放了前端开发者的生产力。

    1 引用
  • C++

    C++ 是在 C 语言的基础上开发的一种通用编程语言,应用广泛。C++ 支持多种编程范式,面向对象编程、泛型编程和过程化编程。

    107 引用 • 153 回帖
  • wolai

    我来 wolai:不仅仅是未来的云端笔记!

    2 引用 • 14 回帖
  • Openfire

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

    6 引用 • 7 回帖 • 94 关注
  • RIP

    愿逝者安息!

    8 引用 • 92 回帖 • 351 关注
  • 京东

    京东是中国最大的自营式电商企业,2015 年第一季度在中国自营式 B2C 电商市场的占有率为 56.3%。2014 年 5 月,京东在美国纳斯达克证券交易所正式挂牌上市(股票代码:JD),是中国第一个成功赴美上市的大型综合型电商平台,与腾讯、百度等中国互联网巨头共同跻身全球前十大互联网公司排行榜。

    14 引用 • 102 回帖 • 376 关注
  • 机器学习

    机器学习(Machine Learning)是一门多领域交叉学科,涉及概率论、统计学、逼近论、凸分析、算法复杂度理论等多门学科。专门研究计算机怎样模拟或实现人类的学习行为,以获取新的知识或技能,重新组织已有的知识结构使之不断改善自身的性能。

    83 引用 • 37 回帖 • 1 关注
  • 面试

    面试造航母,上班拧螺丝。多面试,少加班。

    325 引用 • 1395 回帖
  • TGIF

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

    287 引用 • 4484 回帖 • 669 关注
  • V2EX

    V2EX 是创意工作者们的社区。这里目前汇聚了超过 400,000 名主要来自互联网行业、游戏行业和媒体行业的创意工作者。V2EX 希望能够成为创意工作者们的生活和事业的一部分。

    17 引用 • 236 回帖 • 327 关注
  • GAE

    Google App Engine(GAE)是 Google 管理的数据中心中用于 WEB 应用程序的开发和托管的平台。2008 年 4 月 发布第一个测试版本。目前支持 Python、Java 和 Go 开发部署。全球已有数十万的开发者在其上开发了众多的应用。

    14 引用 • 42 回帖 • 764 关注
  • 学习

    “梦想从学习开始,事业从实践起步” —— 习近平

    169 引用 • 506 回帖
  • SOHO

    为成为自由职业者在家办公而努力吧!

    7 引用 • 55 回帖 • 19 关注
  • JRebel

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

    26 引用 • 78 回帖 • 664 关注
  • 酷鸟浏览器

    安全 · 稳定 · 快速
    为跨境从业人员提供专业的跨境浏览器

    3 引用 • 59 回帖 • 26 关注
  • 区块链

    区块链是分布式数据存储、点对点传输、共识机制、加密算法等计算机技术的新型应用模式。所谓共识机制是区块链系统中实现不同节点之间建立信任、获取权益的数学算法 。

    91 引用 • 751 回帖 • 2 关注
  • 职场

    找到自己的位置,萌新烦恼少。

    127 引用 • 1705 回帖 • 1 关注
  • Gitea

    Gitea 是一个开源社区驱动的轻量级代码托管解决方案,后端采用 Go 编写,采用 MIT 许可证。

    4 引用 • 16 回帖 • 5 关注
  • Unity

    Unity 是由 Unity Technologies 开发的一个让开发者可以轻松创建诸如 2D、3D 多平台的综合型游戏开发工具,是一个全面整合的专业游戏引擎。

    25 引用 • 7 回帖 • 173 关注
  • 前端

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

    247 引用 • 1348 回帖
  • JavaScript

    JavaScript 一种动态类型、弱类型、基于原型的直译式脚本语言,内置支持类型。它的解释器被称为 JavaScript 引擎,为浏览器的一部分,广泛用于客户端的脚本语言,最早是在 HTML 网页上使用,用来给 HTML 网页增加动态功能。

    729 引用 • 1327 回帖
  • SEO

    发布对别人有帮助的原创内容是最好的 SEO 方式。

    35 引用 • 200 回帖 • 22 关注
  • 房星科技

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

    6 引用 • 141 回帖 • 585 关注
  • 以太坊

    以太坊(Ethereum)并不是一个机构,而是一款能够在区块链上实现智能合约、开源的底层系统。以太坊是一个平台和一种编程语言 Solidity,使开发人员能够建立和发布下一代去中心化应用。 以太坊可以用来编程、分散、担保和交易任何事物:投票、域名、金融交易所、众筹、公司管理、合同和知识产权等等。

    34 引用 • 367 回帖
  • MySQL

    MySQL 是一个关系型数据库管理系统,由瑞典 MySQL AB 公司开发,目前属于 Oracle 公司。MySQL 是最流行的关系型数据库管理系统之一。

    690 引用 • 535 回帖
  • Sym

    Sym 是一款用 Java 实现的现代化社区(论坛/BBS/社交网络/博客)系统平台。

    下一代的社区系统,为未来而构建

    524 引用 • 4601 回帖 • 700 关注
  • SendCloud

    SendCloud 由搜狐武汉研发中心孵化的项目,是致力于为开发者提供高质量的触发邮件服务的云端邮件发送平台,为开发者提供便利的 API 接口来调用服务,让邮件准确迅速到达用户收件箱并获得强大的追踪数据。

    2 引用 • 8 回帖 • 483 关注