Trad 0.1.0 开发日志

2019年03月26日  ·  11112 字  ·  阅读

之前看某个 Android 阵营的人在 LCUI 的评论区吹 Dart 语言和 Flutter 框架吹得挺带劲的,跟这类人争论只是浪费时间,即便争赢了他也不会帮你写代码。不过近期正好处于咸鱼模式,想搞点新东西,被他这么一说用 C 写 LCUI 应用程序的确很麻烦,要是有 JavaScript 这样的开发体验就好了,于是就决定搞个语言,搞出来后也能顺便推广 LCUI。为了纪念这位先进的程序员为推动此项目的开发而做出的杰出贡献,语言的名称就定为 Trad。

Trad 语言的定位是 C 语言的扩展,提供 class、async/await、闭包等新特性,以及更好的 UI 开发体验。

首先确定 UI 开发风格,能够参考的有 Vue 和 React 两种:

  • Vue:Vue 组件的对象相当于一个配置集,配置包括 props、data、watch、methods 等,与其说是在写编译器,更像是在写 C 语言版本的 vue-loader,完全照搬 Vue 的编程方式感觉很没意思,而且开发复杂度看上去也高。
  • React: 组件继承自 React.Component,可使用 JSX 语法描述界面,this.state 和 this.props 分别访问组件内部数据和外部传入的数据。class、类方法、state 和 props 的定义以及变量的赋值是主要解析目标,复杂度尚可,可以以此为机会来学习 React。

综上所述,决定采用 React 的风格,接下来是语法解析,从零开始写语法解析器太费时间,所以只能基于开源的语法树解析器来做,可选的有两种:

  • TypeScript:粗略的看了下它的编译器 API 文档,感觉用起来有点复杂,要是基于 TypeScript 的语法的话,好像就没必要增加 C 语法兼容了,而且受众也变小了,正经的会 TypeScript 的前端开发工程师哪会浪费时间玩这个。
  • Acorn:用起来比较容易,支持 JSX 语法。

决定用采用 Acorn,第一个版本先用 JavaScript 的语法,以后再按需求进行扩展。

2019-06-11

解析 class 时应该预先解析全部方法声明,然后再按优先级解析方法内的代码块。

2019-06-09

JSXExpressionContainer 的改进已经完成,接下来还剩两个任务:

  1. 允许在定义 state 和 props 时赋初始值,示例:

    this.props = {
      value: 0,
      total: 100
    }
    this.state = {
      input: 'hello',
      value: 10
    }
    
  2. 给 TextEdit 的 value 属性添加读/写方法,示例:

    // Input
    this.refs.textedit.value = 'hello'
    this.state.input = this.refs.textedit.value
    
    // Output
    TextEdit_SetText(this.refs.textedit, "hello");
    size_t len = TextEdit_GetTextLength(this.refs.textedit);
    char *text = malloc(sizeof(char) * (len + 1));
    TextEdit_GetText(this.refs.textedit, text, len);
    String_SetValue(this.state.input);
    free(text);
    

    获取 TextEdit 的内容比较麻烦,需要分配一块字符串缓存供 TextEdit_GetText() 写入内容,然后将缓存中的内容复制到 this.state.input 中。可以考虑加个 TextEdit_GetProperty() 函数来包装这些代码,然后输出的代码就能简化成这样:

    LCUI_Object value = TextEdit_GetProperty(this.refs.textedit, "value");
    
    Object_Operate(this.state.input, "=", value);
    Object_Delete(value);
    

给部件属性赋值时会调用 operate() 方法输出对应的代码,还好这代码很简单,只有一个函数调用,没其它副作用,但取值就麻烦了,需要插入变量定义和函数调用表达式。现在有两种解决方案:

  • 在插件层处理赋值表达式,如果右值是部件的属性,则转换为 TextEdit_GetProperty() 函数调用
  • 为 CObject 添加 getter() 方法,在解析表达式时尝试调用变量的 getter() 方法

显然,第二个方案最为合适。

2019-06-08

部件内容的数据绑定已经完成,不过还得继续改进:

  • 在解析 JSXExpressionContainer 时应该创建一个函数,然后将该函数作为解析器上下文,继续解析表达式
  • 如果表达式是在部件属性中,则在函数末尾追加 Widget_SetAttrbute() 调用
  • 如果表达式是在部件内容中,则在函数末尾追加 Widget_SetText() 调用

这么一说,JSXExpressionContainer 的解析结果是独立的,可以返回已创建的函数,在解析属性、内容和事件绑定时只需基于这个结果再创建一个包装函数就够了。

输入框是否有必要实现双向绑定?一般情况下按需主动取值也够用,第一个版本就不搞复杂的设计了。

在解析部件属性时,如果属性值类型是 JSXExpressionContainer,则在 state 属性中隐式创建名称以 _exp_ 开头的对象并将部件的属性与之绑定。

JSXExpressionContainer 中有时只有一个标识符,例如:{ this.state.text },给它创建一个函数有点浪费,可以做个判断,如果子结点类型是 ThisExpression 且返回的对象是 CObject 类型则直接调用 Widget_BindProperty() 将它与属性绑定。

state 相关是在解析完 constructor() 后生成的,如果在解析 template() 时再追加属性的话还要手动添加初始化和数据绑定的代码,挺麻烦的,应该在解析完整个 class 后再输出 state 的相关代码。

2019-06-02

解析 const app = new MyApp() 时报错,定义了两个变量,变量赋值时调用 duplicate() 方法来输出对应的对象复制代码,但 MyApp 没有 duplicate() 方法。

这块功能是由 LCUI 插件实现的,需要做些调整:

  • Number 和 String 类型改为从 LCUI 模块引入
  • 整合 BinaryExpression、VariableDeclaration 等解析器到编译器中
  • 对象的类型如果是继承自 CClass 则调用该类提供的方法输出 C 版本的代码,否则不做处理
  • 对象在赋值时如果类方法没有实现 duplicate() 方法则默认输出为赋值表达式

CObject 有两种分配方式:从堆分配和从栈分配,两种方式对应 new() 和 init() 方法,new() 方法返回指针,init() 无返回值。假设 num 类型为 Number 且从栈分配,那么 const num = 0 的解析结果会是:

LCUI_ObjectRec num;

Number_Init(num, 0);

这些代码是在翻译阶段输出的,而代码格式化是在预翻译阶段进行的,如果变量声明多的话输出的代码看上去会很乱。

针对上述问题,补充几个规则:

  • 仅在变量类型为基本类型时支持定义带初始值,例如:int num = 123
  • 类实例对象如果有初始值,则在解析阶段追加一个赋值表达式

2019-06-01

示例程序界面没样式,需要引入 css 文件,给 App 类加个 css 加载方法的话要起个名字,loadCSS?loadStyle?起名挺麻烦的,还要手动调用这个方法。

css 文件就由 import 语句来加载,规则如下:

  • 如果当前文件中存在继承自 LCUI.Widget 的类,则给它的 install() 方法追加 LCUI_LoadCSSString() 函数调用
  • 如果当前文件中存在继承自 LCUI.App 的类,则在构造函数中追加 LCUI_LoadCSSString() 函数调用
  • 如果当前文件同时存在继承自 LCUI.App 和 LCUI.Widget 的类,则优先给 LCUI.App 处理

2019-05-27

类的方法和实例方法在编译时用的是同一命名方式,命名会冲突,在 C 代码中也看不出是类方法还是实例方法。可选的命名方式如下:

  • 以 C 开头:CProgress_Install()
  • 类名以 Class 结尾:ProgressClass_Install()
  • 两个下划线:Progress__Install()

自定义部件需要注册才能使用,那该如何注册?有以下两种方法:

  1. 隐式注册:在 App 的 template() 方法中解析 JSX 代码时,自动注册已使用的部件
  2. 显式注册:在 App 的构造函数中手动调用组件的 install() 方法

第一种只是写起来方便,但没法处理部件的先后依赖顺序,比如有三个部件依赖关系是 A->B->C,必须按顺序注册 C->B->A 才能正常使用部件,所以,选择第二种方法。

2019-05-25

解析对象的方法调用表达式时需要将 CMethod 保存到 CObject 对象里,应该由哪个属性来保存?目前也就 typeDeclaration 属性最适合保存 CMethod。

当 CCallExpression 类的 callee 成员类型为 CObject 时,函数的第一个参数应为 callee 的父级对象。

2019-05-20

在 C 中实现类继承有两种方法:

  • 将基类对象作为子类的第一个成员。好处是可以共用同一块内存空间和地址,坏处是需要暴露基类的全部成员变量和构造/析构函数。

    struct animal { int a; };
    struct human { struct animal _animal; int a; };
    
    void animal_init(struct animal *obj)
    {
      obj->a = 1;
    }
    
    void human_init(struct human *obj)
    {
      animal_init((struct animal*)obj);
      obj->a = 2;
    }
    
  • 子类保存基类对象指针。好处是不用暴露基类的所有成员变量和构造/析构函数。

    struct human { struct animal *_animal; int a; };
    
    void animal_init(struct animal *obj)
    
    void human_init(struct human *obj)
    {
      animal_init(obj->_animal);
      obj->a = 2;
    }
    

2019-05-19

添加 CBinaryExpression 时遇到问题,将调用表达式(CCallExpression)跟对象(CObject)比较时该如何处理?调用表达式的 define() 方法是输出函数调用代码,而 CObject 的 define() 方法则是输出对象的定义,编译时都调用 define() 方法的话结果会是这样:add(a, b) > int b,两边都加上判断的话写起来又挺麻烦的,看上去需要再添加一个方法。

2019-05-18

App 和 Widget 都需要有数据绑定、事件绑定、JSX 语法支持,两个功能不同的类有相同的功能,实现起来有点让人纠结,目前有两种实现方法可选:

  • 拆成两个解析器处理,共用部分相同功能的代码
  • 解析 App 类时隐式创建一个 Widget 类,例如:AppRootWidget,将数据绑定、事件绑定、JSX 等相关代码作为 Widget 类的代码来解析

Widget 的 update() 方法是在更新部件时调用的,那 App 的 update() 方法该在什么时候调用?

2019-05-16

LCUI_Object 类翻译为 C 代码后,类方法都会有 LCUI_Object_ 前缀,LCUI 的命名风格是只有类型带 LCUI_ 前缀,而相关函数由于我个人嫌写起来麻烦所以大都没加 LCUI_ 前缀。看样子需要提前引入 namespace 了,引入后改动如下:

  • 给 CIdentifier 类添加 namespace 属性
  • 给 CIdentifier 类添加 cName 属性,表示对应 C 代码中的名称
  • 给 CIdentifier 类添加 useNamespace 属性,用于控制是否将 namespace 作为名称前缀
  • 给 CClass 类添加 useNamespaceForMethods 属性,用于控制是否将 namespace 作为类方法名称的前缀

2019-05-13

CIdentify 类的大部分数据都是存储在 body 上,给它加个 reference 属性,在该属性有效时则将自身的 body 属性映射为 reference 的 body 属性,这样就能实现类似于指针的功能了。

在解析 import 语句时为导入的对象创建一个引用,编译器可以通过 App.reference.modulePath === 'lcui' 来判断标识符 App 是否引用自 lcui 模块里的对象。

2019-05-12

有如下代码,在解析到类声明时如何判断 App 是来自 lcui 模块?

import App from 'lcui'

class MyApp extends App {}

现在每个对象都有一个路径,在解析 lcui 模块时 App 的路径是 lcui/App,但导入到当前程序后会变为 App,编译器无法识别 App 是来自 lcui 模块。

2019-05-05

用 import 语句导入模块里的对象时,如果完全解析一遍模块里的代码的话会增加编译耗时,考虑到现在解析代码的目的只是为了获取对象的声明,没必要像 JavaScript、Python 这类语言一样加载模块里全部代码,那么可以加个数据文件,功能类似于 C 中的头文件,只记录已导出的对象的声明。

追加需求如下:

  • 编译时追加输出 .json 文件,用于记录模块内已导出的对象的定义,节省编译时间
  • import 解析器在导入模块时优先读取 .json 文件,只导入必要的对象及依赖模块,例如:foo 模块中有 a, b,c 对象,其中 c 对象依赖 bar 模块,现在只导入 foo 模块中的 a 和 b 对象,那么不会导入依赖的 bar 模块

2019-05-04

Widget 有 template() 和 update() 这两个方法,template() 用于声明界面结构、数据绑定和事件绑定,而 update() 用于集中处理与数据相关的一些部件操作。每当数据变化时会标记需要更新,等下一帧部件更新时会调用 update() 方法来更新。

Widget 类的 template() 方法中的根级部件是自身,而 App 类则是新的部件。

2019-05-03

Number 和 String 都继承自 LCUI_Object,如果将 String_Init()String_New() 等专属方法定义为 CMethod 的话,基类的 Object_Operate()Object_ToString() 等方法名的前缀都会变为 String_。只能将这类专属方法定义为 CFunction,保留现有的命名。

类的名称是 LCUI_Object,输出的类方法名会带 LCUI_Object_ 前缀,需要加个成员变量来自定义类方法名前缀。

用 CTypedef 为 CClass 定义一个别名后,所有方法都应该映射到目标 CClass 的方法上,手动写代码一个个映射很麻烦,判断类型时还要专门判断是不是 CTypedef,是的话则获取真实的类型定义。

2019-04-27

如果类继承自 Widget 类,则编译规则如下:

  • 类方法的第一个参数都是 LCUI_Widget 类型的部件指针
  • 类方法中的 this 表达式解析为部件私有数据的指针 _this
  • 类方法中的第一行代码为 Widget_GetData() 函数调用,以获取私有数据的指针 _this
  • constructor() 和 destructor() 方法会绑定到部件原型上
  • 默认创建部件注册方法,命名格式为:LCUIWidget_Add{部件类名}

关于调整类方法参数列表的问题,有以下两种解决方案:

  • 让 CClass 的 createMethod() 方法支持自定义方法的构造器:
    1. 添加一个继承自 CMethod 的 CLCUIWidgetMethod 类,覆写相关方法,在它输出参数列表时插入第一个参数
    2. 在 CClass 里添加 methodClass 成员变量,将 createMethod() 方法改为使用 this.methodClass 作为对象构造器来创建方法
  • 由解析器来创建类方法:
    1. 调整 Class 的解析规则,不预先为类创建全部方法,改为在解析 MethodDefinition 结点时创建方法
    2. 移除 CClass 类的 createMethod() 方法,添加 addMethod() 方法
    3. 添加一个继承自 CMethod 的 CLCUIWidgetMethod 类,覆写相关方法,在它输出参数列表时插入 LCUI_Widget 类型的对象作为第一个参数
    4. LCUI 插件中添加 MethodDefinition 解析器,返回 CLCUIWidgetMethod 对象

第二种方案将类方法的构建交给了解析器,比较灵活。

Widget 构造方法和普通的类不一样,那么 CClass 的 createNewMethod() 和 createDeleteMethod() 方法应该改为由 Class 解析器来实现。

JavaScript 的 class 的 setter 和 getter 要成对存在,不能只继承基类中的 getter 或 setter。

2019-04-21

基于树的结构实现追加、查询、遍历操作就够了,现在整的 types、typesDict、scope、owner、module、parent 等成员变量只是在增加复杂度。

2019-04-11

props 和 state 解析器会互相调用导致栈溢出,两个解析器都有 parseMethodDefinition() 方法,props.js 里调用 super.parse() 时会调用 state.js 的 parse() 方法,而 state.js 调用 super.parse() 又会回到 props.js,一直循环。

RangeError: Maximum call stack size exceeded
    at LCUIParser.parse (c:\Users\LC\Documents\GitHub\trad\src\plugins\lcui\state.js:1:1)
    at LCUIParser.parseMethodDefinition (c:\Users\LC\Documents\GitHub\trad\src\plugins\lcui\props.js:85:26)
    at LCUIParser.parse (c:\Users\LC\Documents\GitHub\trad\src\plugins\lcui\state.js:106:28)
    at LCUIParser.parseMethodDefinition (c:\Users\LC\Documents\GitHub\trad\src\plugins\lcui\props.js:85:26)
    at LCUIParser.parse (c:\Users\LC\Documents\GitHub\trad\src\plugins\lcui\state.js:106:28)
    at LCUIParser.parseMethodDefinition (c:\Users\LC\Documents\GitHub\trad\src\plugins\lcui\props.js:85:26)

原因是 this 指向的是继承类,当前类没有指定方法时会往基类找,在 state.js 找到方法后,调用 super.parse() 实际是调用 props.js 的 parse()。解决方法是显式调用类原型里的方法,例如:

class StateBindingParser extends Compiler {
  // ...

  parse(input) {
    const method = 'parse' + input.type

    if (StateBindingParser.prototype.hasOwnProperty(method)) {
      return StateBindingParser.prototype[method].call(this, input)
    }
    return super.parse(input)
  }

  // ...
}

2019-04-06

class 结构体中的结构体成员应该用实体,而不是指针,这样只需为 class 结构体分配一次内存,省得再调用 malloc() 为结构体成员分配内存。

做些调整,CStruct 和 CClass 用于定义数据结构,CObject 用于定义对象,创建 CObject 对象时会记录所属的类型,然后根据该类型来构造属性列表。

解析 class 时应该先解析所有方法声明,然后再逐个解析方法内的代码块,不然在给部件添加事件绑定时会找不到事件处理函数。

LCUI 组件的数据绑定已经完成,接下来是事件绑定。

2019-04-05

解析 this 的成员表达式时,如果成员在这之前没有定义,则会为该成员定义一个结构体,但是现在的结构体是用 CStruct 对象表达的,既可以是类型也可以是对象,解析时也不好判断是应该定义结构体,还是应该定义结构体的对象,嵌套的结构体处理起来更麻烦。

2019-04-04

开始添加 LCUI_Object 数据结构。

2019-04-03

有些纠结要不要在这个版本中加入数据绑定功能,第一个版本搞这么复杂的功能会增加很多时间成本,以 LCFinder 为例,如果要加的话,需要解决以下问题:

  • 如何高效渲染包含上万张缩略图的列表?
  • 增加和删除若干个图片后,如何高效检测出列表中哪些部件需要改动?
  • 数据类型不同,操作方法都不同,如何设计统一的接口和数据结构?

示例代码如下,text 对象被绑定到了两个部件上,hello() 方法中有 text 的赋值操作。

class MyWidget extends LCUI.Widget {
  constructor() {
    this.state = {
      text: String
    }
  }

  template() {
    return <Widget>
      <TextView>{this.state.text}</TextView>
      <TextEdit value={this.state.text} />
    </Widget>
  }

  hello() {
    this.state.text = 'hello'
  }
}

刚开始的想到的实现方法是将对象的写操作替换为 set() 函数调用,然后将绑定的部件操作放到 set() 函数里,为方便操作部件,会默认记录部件的指针,那么上面的代码翻译成 C 代码大致是这样的:

typedef struct MyWidgetRec_* MyWidget;
typedef struct MyWidgetRec_ MyWidgetRec;

struct MyWidgetRec_ {
        struct {
                LCUI_Widget _textview;
                LCUI_Widget _textedit;
        } refs;
        struct {
                char *text;
        } state;
};

// 省略其它代码 ...

// state 的操作函数都有 `$` 前缀且始终用 static 修饰,与其它函数隔离
static void MyWidget_$SetText(MyWidget _this, const char *text)
{
        _this.state.text = text;
        // 部件原型中有 settext 函数指针,每个部件都可以设置它,所以直接调用 Widget_SetText() 函数
        Widget_SetText(_this->refs._textview, text);
        // 绑定了 value 属性,说明 TextEdit 有处理方法,所以调用 TextEdit_SetValue()
        TextEdit_SetValue(_this->refs._textedit, text);
}

static void MyWidget_Hello(MyWidget _this)
{
        MyWidget_$SetText(_this, "hello");
}

// 省略其它代码 ...

从上述代码中可看出以下问题:

  • 数据绑定功能太依赖编译器,以后要实现手动监听数据变化或类似于 Vue 的计算属性功能会很麻烦
  • 存在内存泄漏问题,无法判断是否需要释放字符串

可以考虑添加一个 LCUI_Object 类型,用来记录数据类型、数据指针、析构函数、watcher 列表等,操作接口包括:创建、销毁、监听、解除监听、赋值等。

数据绑定的问题就不暂时不纠结了,先实现部件事件绑定功能。在 LCUI 中的部件事件处理函数有三个参数:绑定的部件、事件数据、触发器额外传入的数据,那么类的 _this 指针怎么传入?有两种做法:

  • 放到 event->data 中。event->data 比较常用,在 LCFinder 中,每个图片部件都绑定了 click 事件,event->data 存的是与图片对应的文件信息,要是占用的话以后可能又需要改。
  • 封装一次部件处理函数,将 _this 作为第三个参数传入。包装挺麻烦的,要创建一个结构体存储当前 this 指针和事件处理函数,再创建一个函数来转发事件给目标处理函数。

2019-03-31

在 React 中修改数据时需要调用 this.setState() 方法,然后在回调函数中修改状态并将新状态返回出去,这种机制要翻译成 C 代码的话复杂度有点高,还是改用 Vue 的做法吧,允许直接修改属性,等下一帧再批量更新与数据绑定的组件,实现起来简单些,只需要在翻译阶段推断出对象的类型,然后再将赋值语句翻译为对应的函数调用。

需要与组件绑定的数据都放到 state 里,props 中存放外部传入的数据,与其它的类成员隔离。

在翻译 import 语句时只是简单的替换成 #include,没对目标文件进行解析,导入的对象都是默认为 CObject 对象。以后可能需要加上解析功能,输出模块内导出的对象信息到文件里,类似于头文件,以节省以后的解析时间。

2019-03-29

在解析结点后直接输出结果的话不方便后续调整,例如:当 class 有继承基类但没有构造函数时,应该添加一个默认的构造函数;解析完后,需要将 export 修饰的对象和函数输出到头文件里。也就是说,应该在编译前加个解析步骤,将 acorn 解析好的语法树再解析为适合输出 C 代码的语法树,然后再经过编译输出为 C 代码。

解析 ReturnStatement 时需要知道 return 后面的内容是什么,如果是函数调用,那么在解析函数调用时还得判断上级解析器是不是 return 语句,是则不写入结果,而是以返回值形式将结果返回给 ReturnStatement 解析器,让它组装成完整的 return 语句。

2019-03-28

acornjs 的插件是在调用时继承 Parser 类的,要包装一层函数,多了一层缩进,看着难受。手动改 class 的 prototype 来改变父类虽然可行,但不能用 super 来调用基类的方法,会报语法错: SyntaxError: 'super' keyword unexpected here

module.exports = function noisyReadToken(Parser) {
  return class extends Parser {
    readToken(code) {
      console.log("Reading a token!")
      super.readToken(code)
    }
  }
}

2019-03-27

与 LCUI 的相关的解析行为可以独立成插件,这样能方便其他人参考添加其它插件。

2019-03-26

acorn 输出的数据是树形结构,而现在的做法并没考虑到深层结点嵌套的情况,需要做些调整,支持解析单个结点并递归解析子结点。

文章版权归作者所有,未经许可不得转载。

问题反馈

对此文章有疑问?你可以点击 这里 反馈