Javascript 中的 RAII?

RAII 的全称是 “Resource Acquisition is Initialization” 1(资源获取即初始化),它是C++之父Bjarne Stroustrup提出的设计理念,目的是确保异常下的资源管理。换句话说,不管当前作用域以何种方式退出,都会执行资源释放等操作。

C++中 RAII 应用很广,C++11 中的两种基本的锁类型,lock_guard 和 unique_lock ,通过对lock和unlock进行一次薄的封装,实现了自动unlock。

std::lock_guard其功能是在对象构造时将mutex加锁,析构时对mutex解锁,这样一个栈对象保证了在异常情形下mutex可以在lock_guard对象析构被解锁,lock_guard拥有mutex的所有权。

使用try { ... } finally { ... }语句也可以实现类似效果。

下面是在 Node 中写文件为例,我们需要显式关闭流。

function hello(cb) {
  var stream = fs.createWriteStream('example.txt')
  stream.write('Hello World')
  stream.close()
}

如果在两者之间抛出异常,流将泄漏;至少直到下一次垃圾回收为止。

我们可以使用闭包或Promise,创建流然后调用处理程序,最后的finally确保我们可以正确关闭流。

function usingWriteStream(name, handler) {
  var stream = fs.createWriteStream('example.txt')
  try {
    return handler(stream)
  } finally {
    stream.close()
  }
}

function hello() {
  usingWriteStream('example.txt', (stream) => {
    stream.write('Hello World')
  }
}

更进一步,Node中创建流的函数有很多,所以我们还能改造一下,把流的创建函数作为参数。

var usingWriteStream = (name, handler) =>
  usingStream(fs.createWriteStream.bind(null, name), name)

多亏了闭包,我们可以在Javascript中构建类似于RAII的模式,以确保我们的资源不会泄漏。
即使在无法立即识别“资源”的情况下,这在许多情况下也很有用。

例如 Immutable.js 中的 [Map.withMutations()] 使用相似的模式:

const { Map } = require('immutable')
const map1 = Map()
const map2 = map1.withMutations(map => {
  map.set('a', 1).set('b', 2).set('c', 3)
})
assert.equal(map1.size, 0)
assert.equal(map2.size, 3)

这里会创建2个而非4个新的Map,可变 map就是资源。这样可以确保此对象不会轻易泄漏到当前作用之外,并且可以正确地将其转换回不可变 Map。

在C#中有一个 using 语法 ,其实就是 finally 的语法糖。我们可以在TS中去模拟:

interface IDisposable {
    dispose();
}

function using<T extends IDisposable,
    T2 extends IDisposable,
    T3 extends IDisposable>(disposable: [T, T2, T3], action: (r: T, r2: T2, r3: T3) => void);
function using<T extends IDisposable, T2 extends IDisposable>(disposable: [T, T2], action: (r: T, r2: T2) => void);
function using<T extends IDisposable>(disposable: T, action: (r: T) => void);
function using(disposable: IDisposable[], action: (...r: IDisposable[]) => void)
function using(disposable: IDisposable | IDisposable[], action: (...r: IDisposable[]) => void) {
    let disposableArray = disposable instanceof Array ? disposable : [disposable];
    try {
        action(...disposableArray);
    } finally {
        disposableArray.forEach(d => d.dispose());
    }
}


// Usage
class UserNotify { dispose() { console.log("Done"); } }

class Other { dispose() { console.log("Done Other"); } }

using(new UserNotify(), userNotify => {
    console.log("DoStuff");
})
// It will type the arrow function parameter correctly for up to 3 parameters, but you can add more overloads above.
using([new UserNotify(), new Other()], (userNotify, other) => {
    console.log("DoStuff");
})

上面的种种还是没法和RAII比,特别是在释放的资源有多个的时候,只能在 finally 中挨个检查各个资源是否已经被申请。例如:

  void f()
  {
    try {
      acquire resource1;
      … // #1
      acquire resource2;
      … // #2
      acquire resourceN;
      … // #N
    } finally {
      if(resource1 is acquired) release resource1;
      if(resource2 is acquired) release resource2;
      …
      if(resourceN is acquired) release resourceN;
    }
  }

参考文章:

https://stackoverflow.com/questions/47788722/idisposable-raii-in-typescript/47792127#47792127
https://www.zhihu.com/question/20805826

表驱动法(Table-Driven Approach)

1_l7Hjzbw8oHpa39gF8GkW9Q.png

表示原则:把知识叠入数据以求逻辑质朴而健壮。 ——《UNIX编程艺术》

表驱动法是一种编程模式——从表里查找信息而不是使用逻辑语句。
随着逻辑复杂性的增加,if/else 或switch中的代码将变得越来越肿,所以我们常说数据比程序逻辑更易驾驭。表驱动法就是将这些逻辑中的数据与逻辑分开,从而减少逻辑的复杂度。查表方式通常有如下几种:

直接访问

以一个月的天数为例,我们要写一串if/else 或者switch/case 来表达逻辑。

  if(1 == iMonth) {iDays = 31;}
  else if(2 == iMonth) {iDays = 28;}
  else if(3 == iMonth) {iDays = 31;}
  else if(4 == iMonth) {iDays = 30;}
  else if(5 == iMonth) {iDays = 31;}
  else if(6 == iMonth) {iDays = 30;}
  else if(7 == iMonth) {iDays = 31;}
  else if(8 == iMonth) {iDays = 31;}
  else if(9 == iMonth) {iDays = 30;}
  else if(10 == iMonth) {iDays = 31;}
  else if(11 == iMonth) {iDays = 30;}
  else if(12 == iMonth) {iDays = 31;}

但是我们把数据存到一张表里,就不需要冗余的逻辑了。

const month = {
  monthTable: [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31],
  get days(month, year) {
    // (year % 4 === 0) && (year % 100 !== 0 || year % 400 === 0) 闰年逻辑 
    return [month - 1];
  }
}

索引访问

有时通过一次键值转换(对象或者数组),依然无法找到键值。此时可将转换的对应关系写到一个索引表里,即索引访问。而且可以多重索引,或者使用正则匹配。

我们可以利用Map结构的key可以为基本类型这一特点:

const actions = new Map([
  [{identity:'guest',status:1},()=>{/*do sth*/}],
  [{identity:'guest',status:2},()=>{/*do sth*/}],
  //...
])

const onButtonClick = (identity,status)=>{
  let action = [...actions].filter(([key,value])=>(key.identity == identity && key.status == status))
  action.forEach(([key,value])=>value.call(this))
}

进一步的我们也可以利用正则:

const actions = ()=>{
  const functionA = ()=>{/*do sth*/}
  const functionB = ()=>{/*do sth*/}
  const functionC = ()=>{/*send log*/}
  return new Map([
    [/^guest_[1-4]$/,functionA],
    [/^guest_5$/,functionB],
    [/^guest_.*$/,functionC],
    //...
  ])
}

const onButtonClick = (identity,status)=>{
  let action = [...actions()].filter(([key,value])=>(key.test(`${identity}_${status}`)))
  action.forEach(([key,value])=>value.call(this))
}

特别的,对于布尔类型我们还可以使用二维数组或多维数组。

const actions = [
    //!a a
    [1, 2],  // !b
    [3, 4]   //  b 
]

阶梯访问

对于一些无规则的数据,例如等级划分。我们没法使用简单的转换将数据转换为索引,但是我们可以使用一个循环,依次检查区间的上下限。

需要注意的细节

  • 谨慎处理区间端点
  • 可以采用二分/索引加速
const grade = [59,79,84,89,94,100]; 
const level = ["F","E","D","C","B","A"];


const getLevel = g =>{
    for(let i = 0 ; i < grade.length ; i++){
        if(g <= grade[i]) return level[i];
    }
}

表驱动的优势

  • 可读性更强,逻辑一目了然
  • 数据与逻辑解耦,修改数据即可
  • 逻辑可重用

参考文章:

https://www.cnblogs.com/clover-toeic/p/3730362.html
https://juejin.im/post/5dbff51bf265da4d4e3001b2#heading-2
https://medium.com/javascript-in-plain-english/clean-up-your-code-by-removing-if-else-statements-31102fe3b083

弹性布局与最小宽度

By default, flex items won’t shrink below their minimum content size

这意味着项目的最小宽度被设置为其内容的宽度,并且不会缩小到该宽度,所以这里使用overflow: hidden无法让其收缩。

broken.png

flex 布局的元素会默认设置min-widthmin-width默认值为 0,但规范中对于flex项目则设置为auto。这可能会使块状元素占用比预期更多的空间,甚至使它们的容器超出小屏幕的屏幕边缘。

参考阅读:

这里是对规范的讨论 :https://github.com/w3c/csswg-drafts/issues/2248
最新规范:https://drafts.csswg.org/css-sizing-3/#valdef-width-auto

For width/height, specifies an automatic size. See the relevant layout module for how to calculate this.
For min-width/min-height, specifies an automatic minimum size. Unless otherwise defined by the relevant layout module, however, it resolves to a used value of 0. For backwards-compatibility, the resolved value of this keyword is zero for boxes of all [CSS2] display types: block and inline boxes, inline blocks, and all the table layout boxes. It also resolves to zero when no box is generated.

Promise 重试

Promise Retry (1).png

Promise cache 链

Promise 常见的用法是 .then() 链式调用,其实.cache() 也是可以链式调用的。
首先初始化一个Promise.reject() ,之后需要重试几次就后面添加几个cache,如果需要满足一定条件或需要延迟一定时间还可与再继续添加.then(test).catch(rejectDelay);

const max = 5;
let p = Promise.reject();
const attempt = () => {
    console.log(`attempt`)
    return Promise.reject('error')
};

for(var i=0; i<max; i++) {
    p = p.catch(attempt);
    // 可以继续追加 then(test).catch(rejectDelay);
}

p.then(res => console.log(res)).catch(e => console.error(e));

这种方式需要保证:

  • 只有在指定的最大重试次数下才有可能。(链的长度必须有限)
  • 建议使用较小的重试次数。(Promise 链消耗的内存与其长度成正比)

如果不满足这几种情况,使用递归才是最好的选择。

递归

递归方式比较简洁,本质与Promise cache 链的实现是一样的,区别只是在失败之后才添加下一个catch链。

function retry(fn, retries=3, err=null) {
  if (!retries) return Promise.reject(err);
  return fn().catch(err => {
      return retry(fn, (retries - 1), err,);
    });
}

参考文章:
promise-retry-design-patterns

JS 中的协程(Coroutine)

coroutines-xenonstack.png

为何要使用协程

  1. 单线程环境中的并发:一些编程语言/环境只有一个线程。这时如果需要并发,协程是唯一的选择。(注意JS规范没有事件循环)
  2. 简化代码:可以避免回调地狱
  3. 有效利用操作系统资源和硬件:协程相比线程,占用资源更少,上下文更快

何为协程

协程是具有以下功能的函数:

  • 可以暂停执行(暂停的表达式称为暂停点);
  • 可以从挂起点恢复 
(保留其原始参数和局部变量)。

在JS中协程是一种用function*语法标记的生成器函数,函数内部可以使用yield关键字暂停自身。然后可以通过发送一个值来恢复它。发送的值将显示为的返回值,yield执行将继续直到下一个yield。注意JS生成器并不是一个真正的协程,我们可以称之为浅协程

进程、线程和协程的比较

进程:变量隔离,自动切换运行上下文
线程:不变量隔离,自动切换运行上下文切换
协程:不变量隔离,不自动切换运行上下文切换

将协程视为轻量级线程是协程的最直观,最高级的隐喻。与实际线程相比,最大的概念差异是缺少调度程序(所有上下文切换必须由程序完成)

简易协程封装

每次你使用协程你总会做以下三件事。

  1. 调用函数然后返回作为结果的协程对象
  2. 调用协程对象上的next方法,执行到第一个yield
  3. 然后你能做的就是next(),运行协程并发送值给他

我们能把所有功能包装到一个函数中:

function coroutine(f) {
    var o = f(); // instantiate the coroutine
    o.next(); // execute until the first yield
    return function(x) {
        o.next(x);
    }
}

使用这个函数,我们之前的例子变成下面形式:

var test = coroutine(function*() {
    console.log('Hello!');
    var x = yield;
    console.log('First I got: ' + x);
    var y = yield;
    console.log('Then I got: ' + y);
});
// prints 'Hello!'

test('a dog'); ​// prints 'First I got: a dog'
test('a cat'); // prints 'Then I got: a cat

Stackful coroutines

首先,让我们看看什么是有栈协程,他如何工作,并且如何作为一个库实现。可能它比较容易解释,因为他构成和线程相似。
Fibers 或是有栈协程是一个处理函数调用的展开的栈。为了更准确的阐述这种协程的工作方式,我们将从底层的视角简要了解函数的栈帧和调用。那就让我们先来看看 Fibers 的特性。

  • 每个fiber拥有独立的栈
  • fiber的生命周期独立于调用它的代码
  • fiber能从进程中分离出来并加到另一个进程中
  • 不能同时运行在一个进程中

所提及属性的含义如下:

  • fiber的上下文切换必须由用户操作,而不是OS(OS依然可以通过剥夺其所在进程来剥夺fiber)
  • 在同一个进程中的两个fiber之间,不会出现真正的数据竞争,因为只有其一处于活动状态
  • fiber中的I/O操作应为异步,从而其他的fiber可以依次无阻塞的运行

现在,从对函数调用堆栈所做的解释开始,详细解释fiber的工作原理。

栈是内存的连续块,用于存储局部变量和函数参数。
但是,更重要的是,在每个函数调用之后(除少数例外情况),其他信息都会放入栈中,以使被调用函数知道如何返回被调用方并恢复处理器寄存器。

一些寄存器具有特殊用途,并在函数调用时保存在栈中。这些寄存器(在ARM体系结构的情况下)为:

  • SP – stack pointer
  • LR – link register
  • PC – program counter

栈指针SP是一个寄存器,用于保存属于当前函数调用的堆栈起始地址。由于有了这个值,可以很容易地引用保存在栈中的参数和局部变量。
在函数调用期间,链接寄存器LR非常重要。它存储了当前函数执行结束后将要执行的代码的返回地址(到被调用方的地址)。调用该函数时,PC被保存到LR。当函数返回时,使用LR恢复PC。
程序计数器PC是当前执行指令的地址。

每次调用函数时,都会保存链接寄存器,以便该函数知道完成后要返回的位置。

当栈式协程被执行时,被调用函数使用先前分配的栈来存储其参数和局部变量。因为所有信息都存储在栈式协程中调用的每个函数的堆栈中,所以fiber可能会在协程中调用的任何函数中暂停执行。

现在,让我们来看看上面的图片中发生了什么。首先,线程和fiber有自己独立的栈。绿色数字是操作发生的顺序号。

  1. 线程内部的常规函数​​调用,在栈上进行分配。
  2. 该函数创建fiber对象。最终分配了fiber的栈。创建fiber并不一定意味着它会立即执行。
    同样,激活帧被分配。激活帧中的数据是以这种方式设置的,即将其上下文保存到处理器的寄存器中将导致上下文切换到fiber的栈。
  3. 常规函数调用
  4. 调用协程,处理器的寄存器设置为激活帧的内容。
    5,6. 协程内部的常规函数​​调用
  5. 恢复协程–在协程调用期间发生类似的事情。激活帧会记住协程内部的处理器寄存器状态,该状态是在协程暂停期间设置的。

Stackless coroutines

在无堆栈协程的情况下,无需分配整个堆栈。它们消耗的内存少得多,但是因此存在一些限制。

首先,如果它们不为堆栈分配内存,那么它们如何工作?在有协程的情况下,要存储在栈上的所有数据都放在哪里?
答案是:在程序调用栈上。

无栈协程的秘密在于,它们只能从顶级函数中挂起自己。对于所有其他函数,它们的数据都分配在被调用栈上,因此从协程调用的所有函数必须在挂起协程之前完成。协程保留其状态所需的所有数据均在堆上动态分配。这通常需要几个局部变量和参数,其大小远小于预先分配的整个堆栈。

现在我们可以看到,只有一个栈。让我们一步一步地跟踪图片中发生的事情。 (协程激活帧有两种颜色–黑色是存储在栈中的颜色,蓝色是存储在堆中的颜色)。

  1. 常规函数调用,该帧存储在堆栈中
  2. 该函数创建协程。这意味着将激活帧分配到堆上的某个位置。
  3. 常规函数调用。
  4. 协程调用。协程的主体在通常的栈上分配。并且程序流程与常规函数的情况相同。
  5. 从协程调用常规函数。同样,一切仍在栈中。 (注意:协程目前无法暂停,因为它不是协程的顶层函数)
  6. 函数返回到协程的顶级函数(注意,协程现在可以暂停自身)
  7. 协程挂起–将需要在整个协程调用中保留的所有数据放入激活帧。
  8. 常规函数调用
  9. 协程恢复–这是通过常规函数调用发生的,但是会跳到先前的挂起点+从激活帧恢复变量状态。

栈式协程 vs 无栈协程

能够从子函数产生的协程实现称为栈式,即它们可以记住整个调用栈。协程的其他实现(只能从协程函数的顶层执行)只能是无堆栈的。JavaScript 实现就是无栈式的(Python,C#和Kotlin也是如此)。

协程是无限制的协作式多任务任务:在协程内部,任何函数都可以挂起整个协程(函数激活本身,函数调用方的激活,调用方的调用方的激活等)。但是JS只能直接从生成器内部挂起生成器,而只有当前函数激活被挂起。由于这些限制,生成器有时被称为浅协程。

在下面的示例中,我们创建一个生成器,生成一个🐶,然后调用forEach将其传递给匿名函数的数组it => { ... }。由于匿名函数调用仍然算作子函数,因此我们无法yield从内部进行调用。此示例在运行时失败,但是在编译语言中,这将导致编译错误。

function* createGenerator() {
    console.log(yield "🐶");
    [1, 2, 3].forEach(it => {
        console.log(yield it); // runtime error
    });
} 
const c = createGenerator();
console.log(c.next("A"));
console.log(c.next("B"));
console.log(c.next("C"));

用如下方式避免使用回调函数:

function* genFunc() {
    for (const x of ['a', 'b']) {
        yield x; // OK
    }
}

参考文章:
https://blog.panicsoftware.com/coroutines-introduction/
https://dkandalov.github.io/yielding-generators
https://x.st/javascript-coroutines
https://exploringjs.com/es6/ch_generators.html#ch_generators_ref_3