本文非完整的开发指南,具体开发细节请参考官网文档。里面涉及的一些基础概念,翻译自官方文档。
本文主要阐述了 Redux 设计的核心概念,梳理了涉及到的知识结构,方便你能快速地对 Redux 有一个完整的认知。
在此基础上,探索了 Redux 框架的一些基本原理和部分源码的解读。
最后做了一些扩展和延伸。
需要着重申明的是,Redux 本身很简单,简单并不总是意味着容易。Redux 也很强大,作为一种非常简洁优雅的状态管理方案,可以支撑起各种复杂的应用逻辑。随着 Kotlin MultiPlatfrom 的发展,Redux 架构方案也会有更多的应用场景 (纯粹个人预测)。
一言以蔽之: Redux 是一种可预测的状态管理容器 ^1。
Redux 经常和 React 框架一起配合使用,当然 Vue 也是如此。虽然 Redux 本身非常小(只有 2kb),但是却又非常强大的社区生态。
下面简单说一下 Redux 的安装使用(可以直接跳到 总览和概念 这一部分, 并不影响阅读,你可以把下面的部分当做链接导航来使用)。
1 | NPM |
1 | NPM |
假设你在使用 React 框架,那么你很有可能需要下面的依赖:1
2npm install react-redux
npm install --save-dev redux-devtools
npx create-react-app my-app --template redux
更多细节参见官方文档1
更多细节参见官方文档2
更多细节参见官方文档3
更多细节参见官方文档4
更多细节参见官方文档5
Redux is a pattern and library for managing and updating application state, using events called “actions”. It serves as a centralized store for state that needs to be used across your entire application, with rules ensuring that the state can only be updated in a predictable fashion.
翻译:Redux 是一种编程模式,主要用来管理应用状态,通过 Action
来更新状态。它包含了贯穿整个应用生命周期的状态,称之为 Store
, 还有一些规则来保障状态可以被“可预测的”更新。
官网的章节^2是这么描述的:Redux 帮助你管理整个应用共享的“全局状态“。
Redux 让我们更好的理解状态何时何地以及为什么以及怎样被更新的,并且让我们的应用逻辑可预测。Redux 让我们映红可预测,更方便做测试,怎强了应用的鲁棒性。
Redux 和其他工具一样,需要我们做出一些折中。需要我们了解更多的概念,代码量也会增加,代码的编程模式也有更多的限制。在如下场景,从长期来看,短期的妥协还是值得:
Redux 并不是银弹,你需要仔细思考Redux 是否解决你目前遇到的问题,再决定是否使用它。
在真正开始之前,我们先解释一下 Redux 相关的概念和术语。
以计数器(点击按钮,计数器增加 1)为例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16function Counter() {
// State: a counter value
const [counter, setCounter] = useState(0)
// Action: code that causes an update to the state when something happens
const increment = () => {
setCounter(prevCounter => prevCounter + 1)
}
// View: the UI definition
return (
<div>
Value: {counter} <button onClick={increment}>Increment</button>
</div>
)
}
例子包含三部分:
这是一个简单的 ”单项数据流“ 的应用:
计数器的例子比较简单,但是当多个组件消费同一个 State 会让应用越来越复杂,尤其是当组件分布在应用的不同部分。当然,可以通过把 State 提升到功共同父组件来解决,但是实际场景可能不总是这样。
另一个解法,可以通过把状态从组件树中抽离到全局,这样,我们的组件树可以看做 View
,包含的各个组件就可以共享 State 了。
通过重新定义 View
和 State
的概念,并且把两者区分来看,可以增强代码结构的可读性和可维护性。
这就是 Redux 背后的基础概念: 中心化存储应用共享的全局状态,以及更新状态的规则,以此让应用行为变得可预测。
字面意思。
Javascript 对象和数组默认都是可变的。1
2
3
4
5
6
7
8const obj = { a: 1, b: 2 }
// still the same object outside, but the contents have changed
obj.b = 3
const arr = ['a', 'b']
// In the same way, we can change the contents of this array
arr.push('c')
arr[1] = 'd'
例子中,我们更改了内存中相同引用的对象或者数组。
通过拷贝对象和数组,我们来实现 “不可变”。
1 | const obj = { |
Redux 中约定,状态更新都是“不可变” 更新。
Action 就是一个有 type
字段的普通 JavaScript 对象,它描述了事件的一些元信息。
type
字符串描述了 Action 的名字,例如 todos/todoAdded
。
Action 对象可以包含其他字段来来做数据负载,根据约定我们一般叫做 payload
:1
2
3
4const addTodoAction = {
type: 'todos/todoAdded',
payload: 'Buy milk'
}
创建 Action, 避免每次实例化 Action 手写重复样板代码。1
2
3
4
5
6const addTodo = text => {
return {
type: 'todos/todoAdded',
payload: text
}
}
纯函数,参数是当前的 state
和 action
, 输出更新后的 newState
, 函数签名(state, action) => newState
。
1 | const initialState = { value: 0 } |
Redux 应用的状态存储一个叫做 store
的对象。
store 是通过传递 reducer
给 configureStore
来创建的,并且有一个 getState
的方法返回当前的状态。1
2
3
4
5
6import { configureStore } from '@reduxjs/toolkit'
const store = configureStore({ reducer: counterReducer })
console.log(store.getState())
// {value: 0}
store
有另一个方法 dispatch
, 更新 state
的唯一方法就是通过调用 store.dispatch()
函数并且提供一个 action
参数来实现的。store
运行上述我们编写的 reducer
,state
的值更新,然后我们可以通过 getState()
获得更新后的值。1
2
3
4store.dispatch({ type: 'counter/increment' })
console.log(store.getState())
// {value: 1}
Selector
是析构 store
里状态的特定信息的函数。随着应用增长,Selector 可以降低解析同样数据的重复代码。1
2
3
4
5const selectCounterValue = state => state.value
const currentValue = selectCounterValue(store.getState())
console.log(currentValue)
// 2
Middleware 提供了从 dispatch
一个 action
到被reducer
处理之前一种扩展机制,可以用来打印日志,打印错误堆栈,发起异步调用等。
是一种 Middleware,增强 Redux 使其可以 dispatch
一个异步处理函数而非一个简单对象。
以官网的 example/async为例。
1 | src |
1 | import React from 'react' |
这里主要是做了创建 Store 以及一些和 React 框架绑定的事情(Provider, 可以参考 react-redux
)。
这里还涉及到了 middleware 和 thunk,是非常核心的概念,后面会详细解析,但目前不影响理解。
1 | export const REQUEST_POSTS = 'REQUEST_POSTS' |
这里只要定义了 action, 以及发送请求的部分。
1 | import { combineReducers } from 'redux' |
主要是 reducer 纯函数部分。
containers/App.js
和 components/*.js
主要是一些 UI 逻辑,本身并不复杂,这里可自行看官网代码。
首先来看一张没有 Middleware 时候的数据流向图,这对后面理解 Middleware 非常有帮助。
图 2.1 Data Flow 示意图
首先来完整的介绍一下 Middleware。
Middleware 提供了从 dispatch
一个 action
到被reducer
处理之前一种扩展机制,可以用来打印日志,打印错误堆栈,发起异步调用等。
Middleware 主要用来处理异步逻辑(Redux 本身其实并不关心异步逻辑,所有异步操作都需要放到 Store 之外执行)。
Redux 有很多 middleware,最常见的就是 redux-thunk
了,允许你编写含有异步逻辑的函数。
前面章节我们分析了 Redux 的数据流是怎样运转的,当我们引入异步概念后,在 dispatch action 之前我们可以在 middleware 中编写类似发送网络请求的逻辑。更新后的数据流如图 2.2 所示。
图 2.2 Middleware 示意图
1 | import { applyMiddleware, createStore } from 'redux'; |
Redux 提供了 applyMiddleware
方法,该方法支持多个 Middleware 参数传入,例如 applyMiddleware(thunk, promise, logger)
, 并且对于参数的顺序敏感,后面会提到为什么,这里只要记住 logger 要放到最后一个才能正常工作。
当 applyMiddleware
调用结束后,会返回 store
的增强版本,然后传递给 createStore
就完成了 Middleware 的应用。
下面将详解这中间发生的过程。
首先我们来看一下 applyMiddleware.js
的源码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19export default function applyMiddleware(...middlewares) {
return (createStore) => (reducer, preloadedState, enhancer) => {
var store = createStore(reducer, preloadedState, enhancer)
var dispatch = store.dispatch
var chain = []
var middlewareAPI = {
getState: store.getState,
dispatch: (action) => dispatch(action)
}
chain = middlewares.map(middleware => middleware(middlewareAPI))
dispatch = compose(...chain)(store.dispatch)
return {
...store,
dispatch
}
}
}
该方法主要作用是用来增强 Store 的 dispatch
方法,也叫做 store enhancer
。该方法的入参是 middlware 的列表,返回值就是增强后的 Store 了,而且紧紧替换了 dispatch
方法。
虽然代码不多,但是初看确实不好理解。在真正分析源码之前我们首先要了解函数式编程里的两个概念:柯里化和函数组合。
柯里化接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。
说人话,假设有函数 f(x,y) = x + y
,未经柯里化之前 f(2,3) = 2 + 3 = 5
对于柯里化我们可以这样处理:1
2
3首先定义 f-2 = (2, y) = 2 + y
因此
f(2,3) = f-2(y)= f-2(3) = 2 + 3 = 5
函数组合 是通过把一个个简单函数作为参数传递给下一个函数来组合成复杂函数的机制。
假设有:1
2f(x) = x^2 + 3x + 1
g(x) = 2x
那么1
(f * g)(x) = f(g(x)) = f(2x) = 4x^2 + 6x + 1
在 JavaScript 实现函数组合效果是通过 Array.prototype.reduceRight 来实现的。
reduceRight()
方法接受一个函数作为累加器(accumulator)和数组的每个值(从右到左)将其减少为单个值。
举例:1
2
3
4
5const array1 = [[0, 1], [2, 3], [4, 5]].reduceRight(
(accumulator, currentValue) => accumulator.concat(currentValue)
);
console.log(array1);
// expected output: Array [4, 5, 2, 3, 0, 1]
我们再来看一下 compose.js
的代码:1
2
3
4
5
6
7
8
9
10
11
12
13export default function compose(...funcs) {
if (funcs.length === 0) {
return arg => arg
}
if (funcs.length === 1) {
return funcs[0]
}
const last = funcs[funcs.length - 1]
const rest = funcs.slice(0, -1)
return (...args) => rest.reduceRight((composed, f) => f(composed), last(...args))
}
composed
就是上一次累计的结果,初始值是 last(...args)
。执行顺序从右到左,因此第一次 reduce的累加器 (composed, f) => f(composed)
展开代码:(last(...args), f) => f(last(..args))
, 以此类推。
来一个小 demo 验证一下:1
2
3
4
5
6
7
8
9
10
11
12
13var hello = function(x) {
return `Hello, ${ x }`
};
var world = function(x) {
return `${x}!`
};
var compose = function(f, g) {
return function(x) {
return f(g(x));
}
}
var helloworld = compose(hello, world);
helloworld("world")
最终输出结果 "Hello, world!"
。
有了前面的铺垫后,就万事俱备了。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19export default function applyMiddleware(...middlewares) {
return (createStore) => (reducer, preloadedState, enhancer) => {
var store = createStore(reducer, preloadedState, enhancer)
var dispatch = store.dispatch
var chain = []
var middlewareAPI = {
getState: store.getState,
dispatch: (action) => dispatch(action)
}
chain = middlewares.map(middleware => middleware(middlewareAPI))
dispatch = compose(...chain)(store.dispatch)
return {
...store,
dispatch
}
}
}
首先 applyMiddleware
返回的是一个匿名函数,在使用 Middleware 一节我们提到 applyMiddleware
是作为 createStore
参数传递下去的。1
2
3
4const store = createStore(
reducer,
applyMiddleware([ myMiddleware1, myMiddleware2, myMiddleware3])
);
我们来看一下 createStore.js
中 createStore
的核心代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25export default function createStore(reducer, preloadedState, enhancer) {
//部分防护代码删掉
if (typeof enhancer !== 'undefined') {
if (typeof enhancer !== 'function') {
throw new Error('Expected the enhancer to be a function.')
}
return enhancer(createStore)(reducer, preloadedState)
}
//部分防护代码删掉...
//其他关键部分
function getState() {...}
function subscribe(listener) {...}
function dispatch(action) {...}
function replaceReducer(nextReducer) {...}
function observable() {}
}
...
dispatch({ type: ActionTypes.INIT })
return {
dispatch,
subscribe,
getState,
replaceReducer,
[$$observable]: observable
}
applyMiddleware
返回的匿名函数就是 enhancer
这个参数,因此代码会执行到:
return enhancer(createStore)(reducer, preloadedState)
这里 createStore
把自身引用传递到了 applyMiddleware
,即匿名函数的第一个参数 createStore
,applyMiddleware 代码此时会执行到:1
var store = createStore(reducer, preloadedState, enhancer)
知识点来了哦… 此时调用栈代码又回到了 createStore
方法,但此刻 enhancer
参数为空,因此此时 createStore
不会提前 return
, 而是老老实实初始化,最终返回一个 Store 对象:1
2
3
4
5
6
7return {
dispatch,
subscribe,
getState,
replaceReducer,
[$$observable]: observable
}
记住此刻,store 是未经过增强的,执行完毕后,再回到 applyMiddleware
函数。
1 | var chain = [] |
上述几行代码,主要是创建了 middleware 的标准入参(也就是构造 middleware 时候的标准 API),然后遍历 applyMiddleware
传递进来的 middleware 列表,把 {getState, dispatch}
传递给各个 middleware, 最终返回内存中的 middleware 实例。
原本 middleware
的函数声明只这样子的:1
2
3
4
5
6({ dispatch, getState }) => (next) => (action) => {
//执行下一个 Middleware 之前以及 dispatch action 之前执行的操作
let retValue = next(action);
//执行完下一个 Middleware 以及 dispatch action 之后执行的操作
return retValue;
};
经过上述 chain = middlewares.map(middleware => middleware(middlewareAPI))
操作后(柯里化的参数应用,消灭掉第一个参数 { dispatch, getState }),
内存中的 middleware 转化成如下结构:
1 | (next) => (action) => { |
假设我们有三个 Middleware myMiddleware1, myMiddleware2, myMiddleware3
定义如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20const myMiddleware1 = ({ dispatch, getState }) => (next) => (action) => {
console.log('myMiddleware 1 start');
let retValue = next(action);
console.log('myMiddleware 2 end');
return retValue;
};
const myMiddleware2 = ({ dispatch, getState }) => (next) => (action) => {
console.log('myMiddleware 2 start');
let retValue = next(action);
console.log('myMiddleware 2 end');
return retValue;
};
const myMiddleware3 = ({ dispatch, getState }) => (next) => (action) => {
console.log('myMiddleware 3 start');
let retValue = next(action);
console.log('myMiddleware 3 end');
return retValue;
};
首先经过第一个参数部分求值后:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20const myMiddleware1 = (next) => (action) => {
console.log('myMiddleware 1 start');
let retValue = next(action);
console.log('myMiddleware 2 end');
return retValue;
};
const myMiddleware2 = (next) => (action) => {
console.log('myMiddleware 2 start');
let retValue = next(action);
console.log('myMiddleware 2 end');
return retValue;
};
const myMiddleware3 = (next) => (action) => {
console.log('myMiddleware 3 start');
let retValue = next(action);
console.log('myMiddleware 3 end');
return retValue;
};
接下来就是函数组合,也是 Redux 中精华的部分了:1
dispatch = compose(...chain)(store.dispatch)
转换后的 dispatch 变成如下结构:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21function dispatch (action) {
var next = (next) => {
var next = (next) => {
return (action) => {
console.log('middleware 3 start');
(store.dispatch)(action)
console.log('middleware 3 end');
};
};
return (action) => {
console.log('middleware 2 start');
next(action)
console.log('middleware 2 end');
};
};
return (action) => {
console.log('middleware 1 start');
next(action)
console.log('middleware 2 end');
};
};
根据展开后的代码可以得出结果,当我们 dispatch
一个 action
的时候,
首先会按照从做到右的顺序执行 Middleware, 直到最后一个 Middleware,而最后一个 Middleware 的 next 参数已经在 compose
的过程中被赋值为 (store.dispatch)
, 注意此时的 dispatch 是未经过修饰的。因此当 middleware3 执行完毕后,此时 action 才会真正被送到 reducer
进行状态变更。
可以结合图 2.2 来理解。
图 2.2 Middleware 运行机制示意
因此控制台输出结果为:1
2
3
4
5
6
7
8//state
middleware 1 start
middleware 2 start
middleware 3 start
//nextState
middleware 3 end
middleware 2 end
middleware 1 end
最后 applyMiddleware
函数返回加强后的整个应用的 store
实例:1
2
3
4return {
...store,
dispatch
}
剥洋葱图。
以 redux-logger 为例,必须放到最后一个,否则它只能记录 thunk 或者 promise,而非最终的 action,具体参见。
原理就是如果不放置到最后一个的话,当前的 middlware 并不发送 action,而只是调用 next 传递给下一个 middleware,结合图 2.2,只有 middleware 3 才能记录真正经过 middleware 1 & 2 处理后发送的 action。
Thunk中文翻译有转换程序的意思。下面我们看一下 redux-thunk 到底转换了什么。
嗯,redux-thunk 代码就这么多。1
2
3
4
5
6
7
8
9
10
11
12
13
14function createThunkMiddleware(extraArgument) {
return ({ dispatch, getState }) => (next) => (action) => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument);
}
return next(action);
};
}
const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;
export default thunk;
核心逻辑 typeof action === 'function'
判断如果是 function,则调用 function。这使得 action 脱离了简单对象的约束,现在可以返回 function 对象。该 function 接受 getState,dispatch
作为参数,可以用来延时 dispatch action。
例如如下 action creator:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16const INCREMENT_COUNTER = 'INCREMENT_COUNTER';
function increment() {
return {
type: INCREMENT_COUNTER,
};
}
function incrementAsync() {
return (dispatch) => {
setTimeout(() => {
// Yay! Can invoke sync or async actions with `dispatch`
dispatch(increment());
}, 1000);
};
}
去掉各种细节逻辑,处理逻辑也很简单,就是在 returnedValue 前后记录一些信息。1
2
3
4
5
6
7({ getState }) => next => (action) => {
log....
let returnedValue;
returnedValue = next(action);
log....
return returnedValue;
};
细节实现参见redux-logger代码
redux-saga 是一个用于管理应用程序 Side Effect(副作用,例如异步获取数据,访问浏览器缓存等)的 library,它的目标是让副作用管理更容易,执行更高效,测试更简单,在处理故障时更容易。
可以想像为,一个 saga 就像是应用程序中一个单独的线程,它独自负责处理副作用。 redux-saga 是一个 redux 中间件,意味着这个线程可以通过正常的 redux action 从主应用程序启动,暂停和取消,它能访问完整的 redux state,也可以 dispatch redux action。
redux-saga 源码稍微有点多,后面考虑单独开坑来写。
支持 promise 对象。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16export default function promiseMiddleware({ dispatch }) {
return next => action => {
if (!isFSA(action)) {
return isPromise(action) ? action.then(dispatch) : next(action);
}
return isPromise(action.payload)
? action.payload
.then(result => dispatch({ ...action, payload: result }))
.catch(error => {
dispatch({ ...action, payload: error, error: true });
return Promise.reject(error);
})
: next(action);
};
}
更多信息参考redux-promise 代码
当 “变化” 和 “异步” 混在一起,会让我们的应用越来越复杂,就类似曼妥思和可乐混在一起,后果可想而知。
类似于 React 框架分离 View 和对 DOM 的操作,但是应用状态的管理缺撒手不管,这就是 Redux 存在的意义。
随着应用复杂度的提高,我们需要管理越来越多的状态,Redux 让我们重新掌握对状态管理的控制权。
应用的全局状态只存在单一对象 store
中。
更改应用状态的唯一方法就是 dispatch
一个 action
。
根据 action
转换 state
的方法(也就是 reducer
) 必须是纯函数。
实际应用开发中可能会用到的一些案例。
本文中默认的都是 JavaScript 版本,由于 Redux 只是一种编程模式,因此可以应用 Android 和 iOS 以及其他 Desktop 的开发技术栈。
https://github.com/reduxjs/redux
https://github.com/reduxkotlin/redux-kotlin
xcode-select --install
brew install mercurial
brew install freetype
注:这里使用老版本的 Xcode,因为 Xcode 10 后,libstd
废除,改用 libc++
,导致编译错误。
hg clone http://hg.openjdk.java.net/jdk8/jdk8 JDK8
cd JDK8
bash ./get_source.sh
创建 envsetup.sh
文件。
touch envsetup.sh
写入如下内容:
1 | # 设定语言选项,必须设置 |
然后执行 source envsetup.sh
。
1 | bash ./configure \ |
编译!
make all
编译结束后,会看到如下图输出:
1 | $ cd build/macosx-x86_64-normal-server-slowdebug/jdk/bin |
这里 slowdebug 会出现 crash,需要注释掉如下代码:
File->ImportProject
,导入 Hotspot 目录。
Clion 会生成 CMakeList 文件。不用理会,无脑 next。
在 Clion 中,Edit configuration
:
下一步点击 debug
按钮:
Happy Hacking!
但复杂的计算机程序远非单个语句和表达式的累加那么简单。一旦把许多小零件组合到一起构成更大的整体,能检查整个程序是否实际做了我们想要它做的事会很有用。例如,我们可能想要知道它总是返回确定的结果,或者运行这个程序能对文件系统或者网络有既定的副作用,或者只是不含有明显的一遇见非期望输入就会导致崩溃的 bug。
最直接的查明一个程序将会做什么的途径就是执行它,有时候这确实没问题——大量的软件测试就是通过基于已知的输入运行再根据期望的输出检查结果完成的——但有时候运行代码可能也不是一种可接受的方式,原因如下。
首先,任何有用的程序有可能会处理直到运行时才知道的一些信息:来自用户的交互式输入,作为参数传进来的文件,从网络读取的数据,诸如此类的东西。
还有一个问题,就是用足够强大的语言写成的程序可以永远运行而从来不会产生结果。这让通过运行程序来可靠地研究任意程序变得不可能,因为有时候不可能预先说出一个程序是否会无限运行。
最后,即使一个程序不管什么原因,它事先所有的输入数据都可用,而且总是能终止而不会永远循环,运行这个程序的代价也可能非常高或者很不方便。
所有这些原因让能够不实际执行程序就能发现它的问题变得很有用。做到这一点的一种方式是使用抽象解释,这是一种分析技术。使用这种技术时,我们执行这个程序的简化版本,然后使用执行结果推导出原始程序的性质来。
抽象解释给了我们一种着手处理难处理问题的方法,这些难处理的问题或许过于庞大,过于复杂,或者有太多的未知东西难以直接处理。抽象解释的主要思想就是使用抽象,或者通过让它更小,更简单,或者通过去掉未知的东西,但这样做还能保留足够的细节,以便让它的解决方案与原始问题相关。
到目前为止,我们已经看到了如何不实际执行计算就能发现它的近似信息。我们本可以通过实际执行计算来获得更多信息,但近似的信息比没有还是要强,而且对于某些程序(如路线规划) ,可能这就是我们所需要的全部了。
我们了解了编程语言的动态语义,一种定义代码运行时含义的方法。一种语言的静态语义告诉我们程序性质,无需执行就可以研究。静态语义的经典例子就是类型系统:它是一个能用来分析程序的规则集合,能检查其中是否含有某种 bug。
我们对抽象解释的讨论非常不正式。正式来讲,抽象解释是一种数学化的技术,同样语言的不同语义通过函数连接到一起,这些函数把具体值的集合转换成抽象值的集合,反之亦然。这就允许抽象程序的结果和性质可以按照具体程序的方式来理解。
这项技术一个著名的工业级应用是 Astrée 静态分析器(http://www.astree.ens/fr/) ,它使用抽象解释自动证明一个 C 程序没有像被零除、数组越界和整数溢出这样的运行时错误。
Astrée 不仅已经用来验证为国际空间站运送补给的儒勒·凡尔纳(Jules Verne)ATV-001 任务的自动对接软件,还被用来验证空客 A340 和 A380 飞机的飞行控制软件。抽象解释通过提供安全的近似而不是有保证的答案来遵循 Rice 理论,因此 Astrée 有可能报告实际不存在的运行时错误(错误警告) ;实际上,它的抽象在验证 A340 软件时准确到足以避免任何错误的警告。
我们已经看到的最先进的机器——图灵机,似乎拥有我们需要的一切:拥有无限制的存储,这个存储能以任何顺序、在任意的循环中、在任意的条件语句以及子例程中访问。
我们也看到了极小编程语言 lambda 演算,被证明也出奇得强大:稍加精心设计,它就允许我们把简单的值和复杂的数据结构都表示成纯代码,还能实现操纵这些表示的运算。我们看到了许多简单的系统,就像 lambda 演算一样,它们也与图灵机有着同样的通用能力。
我们还能将系统不断增强的过程推进多少?或许并不是不确定的:我们通过增加特性让图灵机做更强大的尝试,但没有取得任何进展,这表明计算能力可能存在着一种硬性的限制。那么计算机和编程语言的基本能力是什么呢?有什么它们做不到的事情吗?存在不可能的程序吗?
通常说来,使用像图灵机、lambda 演算和部分递归函数这样的通用系统我们能干什么呢?如果我们能恰当理解这些系统的能力,那就可以考察一下它们的限制。
计算机的实际目的就是执行算法。算法是一个指令列表,指令描述把一个输入值转成一个输出值的过程,但必须满足某些条件:有限,简单,终止,正确。
比如我们把欧几里得算法的自然描述转换成形式化的机器指令,而且没有歧义。但是,任何算法都能转换成适合一台机器执行的指令吗?表面上看,这个问题似乎不值一提——如何把欧几里得算法转换成一个程序相当明显。而作为程序员,我们有天然的倾向会把两者看成可互换的——但在一个计算系统中,一个算法抽象的、直觉的思想与具体的、逻辑上的实现是存在实质差别的。是否存在一个算法,它大、复杂而且不同寻常以致于其本质无法被一个没有思想的机械过程捕捉呢?
最终可能没有严谨的答案,因为这个问题是哲学层面的而非科学层面的。一个算法的指令一定要“简单”而且“不精巧” ,以便它“能由一个人计算” ,但这些对人类的直觉和能力来说都是不严密的,这并不是能用来证实或者推翻一个假设的数学化断言。
不管怎样,我们都可以通过提出大量算法并观察我们选择的计算系统(图灵机、lambda 演算、部分递归函数,或者 Ruby)是否能够实现它们来收集证据。数学家和计算机科学家差不多从 20 世纪 30 年代开始就已经在这么做了,但到目前为止还没有人成功设计出这些系统不能执行的算法。因此我们可以对经验上的直觉相当自信:一台机器肯定能执行任何算法。
另一个比较强的证据是这些系统中大多数都是为了尝试捕捉和分析一个算法的非形式化思想而独立发展的,只是后来才被发现彼此之间恰好等价。每一次对算法思想的建模尝试都产生了一个系统,这个系统的能力与一台图灵机的能力等价,而这是对一台图灵机足够表示一个算法的很好暗示。
任何算法都能被一台机器(特别是一台确定型的图灵机)执行的思想叫作邱奇 - 图灵论题(Church–Turing thesis) 。尽管这仅仅是一个猜想而不是一个被证明的事实,但有足够的证据让它成为广泛接受的真理。
图灵机能执行任何算法”是个哲学层面的断言,说的是算法的直观感觉和用来实现算法的形式系统之间的关系。它实际的含义是一个解释的问题:我们可以把它看成关于什么能计算以及什么不能计算的命题,或者作为单词“算法”的更严格的一个定义。
不管怎样,它都叫“邱奇 - 图灵论题” ,而不是“邱奇 - 图灵定理” 。因为它是一个非形式化的断言而不是一个可证明的数学断言——它没法用纯数学化的语言表达,因此没有办法构建数学证明。因为它与我们对计算本质的直觉判断和算法能做事情的证据相符,所以被广泛认为是真的,但我们仍旧称它为“论题” ,以便提醒自己它的状态与毕达哥拉斯定理这样的可证明思想不同。
邱奇 - 图灵论题表明,图灵机尽管简单,但拥有执行任何计算所需要的所有能力,而这些计算原则上可以由一个人按照简单的指令执行。许多人比这更进一步,他们认为,既然所有对算法编码的尝试都归结到了与图灵机能力等价的通用系统上,那也就不可能做得更好了:任何现实世界中的计算机或者编程语言只能做到与图灵机做的一样多的事,不能再多了。是否最终有可能构建一台比图灵机更强大的机器——能使用外来的物理法则执行超越我们对“算法”想象的任务——现在还不能确切知道,但可以肯定的是我们现在不知道如何做。
图灵机的简单性使得为一个特定任务设计一个规则手册非常困难。为了避免对可计算性的研究被图灵机编程烦琐的细节干扰,我们将使用 Ruby 程序作为替身,就像处理欧几里得算法那样。
这个方法可行要归因于通用性:原则上,我们可以把任何的 Ruby 程序转换成一个等价的图灵机,反之亦然。因此一个 Ruby 程序与一台图灵机相比不多不少正好能力相当,从而我们发现的关于 Ruby 能力的任何限制都应该可以同样适用于图灵机。
程序有两种身份。除了把程序当作控制一个特定系统的指令之外,我们还把程序看成是纯数据:一个表达式树,一个原始字符串,或者甚至一个大的数。这种双重性通常会被程序员认为理所当然,但程序能够被表示成数据以便它们能用做提供给其他程序的输入,对通用计算机来说是至关重要的。正是代码和数据的统一才使得软件成为可能。
我们在通用图灵机的讨论中已经看到了作为数据的程序,它期望另一台图灵机的规则手册能作为字符序列写到它的纸带上。像 Lisp 和 XSLT 这样奇特的同体异构编程语言(即程序与数据由同样的结构存储) ,程序被显式地写成语言本身可以操纵的数据结构:每一个Lisp 程序是一个称为 s 表达式的嵌套列表,而每一个 XSLT 样式表是一个 XML 文档。
我们已经看到通用目的的计算机是通用的:可以设计一台能模拟其他任何图灵机的图灵机,或者写一个能对其他任何程序求值的程序。通用性是个强大的思想,这样不同的任务只用一台可改写的机器而不是很多专门机器就可以完成。但它也有不方便的地方:任何强大到足以通用的系统,都不可避免地允许我们构建永不停机一直循环的计算。
那么为什么每个通用系统都把非终结作为属性呢?有没有什么天才的方法能限制图灵机以便它们总是能停机,而不必在它们的用处上做出妥协呢?怎么知道我们某一天不会设计出一种编程语言,它与 Ruby 一样强大但不包含无限循环呢?
被仔细地设计以保证它们的程序一定总是能停机的语言叫作完全编程语言。与之相对的是更常见的部分编程语言,这样语言的程序有时候能停机给出答案,有时候不能。完全编程语言仍然非常强大,能表达许多有用的计算,但它们不能做到的就是解释自身。
让一个程序从标准输入读取它自己的源代码只不过是一个对所有通用系统都能完成的某个事情的为简化,而且与它们的环境和其他特性无关。这是一个叫作 Kleene 第二递归定理的推论(Kleene’s second recursion theorem) ,它保证了任何程序都可以转换成能计算自身源代码的等价物。
到目前为止我们已经看到图灵机有非常多的能力和灵活性:它们可以执行编码成数据的任意程序,执行我们能想出来的任意算法,运行无限长时间,对它们自身的描述进行计算。尽管它们很简单,可这些小的假想的机器都已经被证明能表示一般的通用系统。
如果它们这么强大而灵活,那是否存在图灵机乃至真实世界的计算机和编程语言不能做的事情呢?
在回答这个问题之前,需要让这个问题更明确一些。我们可以让一台图灵机做什么样的事情呢?怎么识别它已经干完了呢?需要研究每一种可能的问题吗?或者只考虑其中一部分问题是否足够呢?我们只是在寻找解法超越自己当前理解的问题,还是在寻找已经知道永远不能解决的问题呢?
如果存在一个算法,对任何可能的输入都能保证在有限时间内解决一个判定性问题,那么这个问题就是可判定的(或者叫可计算的) 。邱奇-图灵论题认为每一个算法都能由图灵机执行,所以对于一个可判定性的问题,我们需要设计一台总是产生正确答案的图灵机,并且如果运行足够长的时间,它总是能停机。把一台图灵机的最终配置解释成“是”或者“否”的答案是很简单的:例如可以检查在当前纸带的位置上是否写有 Y 或者 N,或者完全忽略纸带内容,而只是检查它的最终状态是接受状态( “是” )还是非接受状态( “否” ) 。
有许——无限多——多判定性问题而且大量的问题是不可判定的:没有保证能停机的算法能解决它们。这些问题中每一个都是不可判定的,不是因为我们还没有找到合适的算法,而是因为问题本身从本质上就对某些输入不可能解决,而我们可以证明永远也不会找到合适的算法。
大量的非判定性问题是关于机器和程序执行过程中的行为的。这其中最著名的就是停机问题,停机问题要解决的是对拥有一条特定纸带的特定图灵机判定它的执行是否能够停机。
感谢通用性,我们可以把同样的问题用更实际的名词重讲一遍:给定一个包含 Ruby 程序源代码的字符串,还有一个数据的字符串可以让程序从标准输入中读取,那么运行这个程序最终会得到一个答案作为结果还是只会无限循环下去呢?
完全解决停机问题是不可能的,而且既然 Ruby 程序与图灵机等价,所以图灵机也是不可能的。邱奇-图灵论题说的是所有的算法都能由一台图灵机执行,因此如果不存在能解决停机问题的图灵机,也不会存在算法;换句话说,停机问题是不可判定的。
停机问题不是唯一的不可判定问题。我们日常构建软件的过程中可能想要解决大量问题,而它们的不可判定性对于自动化工具和过程的实际限制非常重要。
Rice 定理:程序行为的任何非平凡性质都是不可判定的,因为停机问题总是能被规约成判定这个属性是否为 true 的问题;如果我们能发明一个算法来判定那个属性,就能使用它来构建另一个算法来判定停机问题,而这是不可能的。
不可判定性是生命中麻烦的一个事实。停机问题令人失望,因为它表明我们无法拥有一切:我们想要的是能力不受限制的通用编程语言,但还想要写出程序产生一个不会陷入无限循环的结果,或者至少是子例程作为某个更大的长期运行任务的一部分能停机。
Rice 定理的暗示也是令人沮丧的:不止“程序是否会停机”这个问题是不可判定的, “程序是否做了我想让它做的”也是不可判定的。我们生活的宇宙当中,没法构建一台机器能准确预测一个程序是否能输出 hello world,是否会计算一个特定的数学函数或者是否能做一个特定的操作系统调用,而这就是它的运行方式。
那是令人沮丧的,因为能够机械地检查程序性质实在是非常有用的;有了一个工具能判定程序是否遵守它的规范或者含有任何的 bug 之后,现代软件的可靠性将会提高。那些性质可能对于个体程序是可以机械地检查出来的,但除非它们通常都能检查出来,不然我们将永远不能信任机器来做这些工作。
在所有这些不便之下有两个基础问题。第一个是我们没有能力预测程序执行的时候会发生什么;弄清楚一个程序做什么的唯一通用方法就是真正运行它。尽管一些程序足够简单,行为直接是可预测的,但仅仅通过分析它们的源代码,通用语言总是会允许行为不可预测的程序存在。
第二个问题是,在我们确实决定运行程序的时候,没有可靠的方式知道它多久能运行完。
唯一通用的解决方案是运行程序然后等它执行,但既然我们知道通用语言的程序有可能不停机永远循环下去,那么总是存在一些程序无论等待多久都运行不完。
我们已经看到所有通用系统都足够强大,可以引用自身。程序对数字进行运算,数字可以表示字符串,而一个程序的指令只用字符串写下来的,因此程序完全能够对它们自己的源代码进行运算。
自引用能力使得写出能准确预测程序行为的程序成为不可能的事情。一旦一个特别的行为检查程序写完了,我们总是能构建一个更大的程序打败它:新程序把这个检测器当作一个子例程,检查它自身的源代码,然后立即做与检测器要做的相反的事情。这些自我矛盾的程序比我们实际写出来的一些东西更奇特,但它们只是一个征兆,而不是潜在问题的根因:通常,程序行为过于强大而无法准确预测。
一言以蔽之,程序行为这么难预测有两个原因:
写一个程序的所有要点就是让计算机做有用的事情。作为程序员,我们该如何应对无法检测程序是否正确工作这个事实呢?
拒绝是一个吸引人的选择:忽略整个问题。如果能自动校验程序行为当然好,但我们不能,所以只是期望做到最好,而永远不要检查一个程序在正确地完成它的工作。
但这属于反应过度,因为情况没有听起来那么坏。Rice 定理并不意味着分析程序不可能,而只是我们不可能写出一个不平凡的总是停机并产生正确答案的分析器。
没有什么可以阻止我们写一个工具来为某些程序给出正确答案,只是我们得承认总是会存在其他程序要么给出错误答案要么永远循环不返回任何东西。
不考虑不可判定性,下面是一些分析和预测程序行为的实用方法。
lambda 演算是一种可用的编程语言,但还没有探讨它是否与图灵机一样强大。事实上,lambda 演算一定至少有那么强大,因为它能够模拟包括通用图灵机(当然包括)在内的任何图灵机。
通用系统的真正好处是它能被编程以执行不同的任务,而不是总要硬编码来。
特别地,通用系统能被编程来模拟任何其他的通用系统;通用图灵机能计算 lambda 演算表达式的值,而 lambda 演算解释器也能模拟图灵机。
就像 lambda 演算一样,SKI 组合子演算是一个处理表达式语法的规则系统。尽管 lambda 演算已经很简单了,但仍然还有三种表达式:变量、函数和调用。
SKI 演算更简单,它只有两种表达式:调用和字母符号,规则也更简单。它所有的能力都源于三个特别的符号 S、K 和 I(叫作组合子) ,它们每一个都有自己的归约规则:
SKI 演算用三个简单的规则就产生了出人意料的复杂行为。事实上,复杂到被证明是通用的了。我们可以证明 SKI 表达式的通用性,方法是展示如何把任意的 lambda 演算表达式转换成做同样事情的一个 SKI 表达式,这实际上也是使用 SKI 演算给了 lambda 演算一个指称语义。我们已经知道 lambda 演算是通用的,因此如果 SKI 能完全模拟它,就能得出SKI 演算也是通用的结论。
希腊字母约塔(ɩ)是可以添加到 SKI 演算里的另一个组合子。下面是它的规约规则:ɩ[α] 可以规约成 α[S][K]。
Chris Barker 提交了一种叫作 Iota(http://semarch.linguistics.fas.nyu.edu/barker/Iota/)的语言,它的程序只使用 ɩ 组合子。尽管只有一个组合子,Iota 仍然是一种通用语言,因为任何 SKI 演算表达式都可以转成它,而我们已经看到 SKI 演算是通用的。
可以通过应用这些替换规则把 SKI 表达式转成 Iota:
标签系统(tag system)是一个类似简化版图灵机的计算模型:标签系统不是在一条纸带上来回移动纸带头,而是反复在一个字符串的末尾增加新的字符并在开头处移除字符。在某方面,标签系统的字符串像是图灵机的纸带,但标签系统被限定在只能在字符串的两头操作,而且它只能朝着末尾“移动” 。
标签系统的描述包括两部分:首先,一个规则集合,其中每一条规则定义当特定的字符出现在字符串的开头时,要给这个字符串添加的一些字符(例如“字符串的开头是字符 a 时,添加字符 bcd” ) ;其次,一个叫作删除数的数字,它定义了按照一个规则执行之后有多少字符要从字符串的开头删除。
标签系统只能直接在字符串上操作,但我们也可以让它们对其他类型的值(例如数字)执行复杂的操作,只要用合适的方式把那些值编码成字符串就行。对数字编码的一种可能方式是:把数字 n 表示成字符串 aa 后跟重复 n 次的字符串 bb。例如,把数字 3 表示成字符串 aabbbbbb。
对于标签系统如何模拟图灵机工作的完整说明,请看 Matthew Cook 在 http:// www.complex-systems.com/pdf/15-1-1.pdf 中 2.1 节所做的简洁解释。
标签系统可以模拟任意图灵机的事实,意味着它也是通用的。
循环标签系统(cyclic tag system)是施加了一些额外限制的更简单的标签系统。
这些约束本身对于支持任何有用的计算来说都过于苛刻了,因此作为补偿循环标签系统有一个额外的特性:循环标签系统的规则手册中的第一条规则是执行开始时的当前规则,并且在计算的每一步之后,规则手册中的下一个规则就成为了当前规则,在到达规则手册结尾的时候又会回到第一个规则。
这种系统被称为“循环的” ,是因为当前规则不断地在规则手册中循环。一个当前规则,再结合上每条规则都只会应用到 1 开头的字符串这一约束,避免了在每一步执行中不得不遍历规则手册查找可用规则的开销。如果首字符是 1,那么就应用当前规则,否则,就没有可用的规则。
循环标签系统极其受限(它们的规则不灵活,只有两个字符,删除数也是最低值) ,但令人吃惊的是,仍然可以使用它们模拟任何标签系统。
循环标签系统也是通用的。
1970 年,John Conway 发明了一个叫作生命游戏(Game of Life)的通用系统。 “游戏”要在一个无限多的二维网格里进行,网格的每个小方格可以是生或是死。一个小方格有 8 个邻居:它上面的三个单元,紧挨着它的左右两个单元,以及它下面的三个单元。
生命游戏像有限状态机那样分一系列步骤进行。在每一步,根据由这个单元自身的当前状态和它邻居的状态所触发的规则,每个单元都可能从生转变为死,或者相反。规则很简单:如果一个活着的单元有少于两个(人口稀少)或者多于三个(人口过剩)活着的邻居,它就会死掉,如果一个死的单元恰好有三个活着的邻居它就能复活(繁殖) 。
1982 年,Conway 除了展示如何靠以创造性的方式碰撞“滑翔机”来设计逻辑上的与门(AND) 、或门(OR)和非门(NOT)以执行数字计算之外,还展示了如何使用一连串的“滑翔机”来表示二进制数据。这些结构说明理论上可以用生命游戏模拟一个数字计算机,但 Conway 没有设计出来一台可工作的机器。
2002 年,Paul Chapman 实现了一个特种通用计算机(http://www.igblan.free-online.co.uk/igblan/ ca/) 。而 2010 年,Paul Rendell 构造出了一台通用图灵机(http://rendell-attic.org/gol/utm/) 。
rule 110 是另一个细胞自动机,由 Stephen Wolfram 在 1983 年提出。与 Conway 生命游戏里每个单元要么是生的要么是死的类似,rule 110 操作的单元按一维排列而不是二维网格形式。这意味着每个单元只有两个邻居而不是围绕着每个生命游戏单元的 8 个邻居。
2004 年,Matthew Cook 发表了一个对 rule 110 事实上通用的证明。这个证明包含大量的细节(参考 http://www.
complex-systems.com/pdf/15-1-1.pdf 的第 3 节和第 4 节) 。但粗略地讲,它引入了几个不同的 rule 110 模式扮演“滑翔机”的角色,然后通过用一种特定的方式排列那些“滑翔机”来展示如何模拟任意循环标签系统。
这意味着 rule 110 可以运行一个循环标签系统的模拟,而循环标签系统又可以运行一个普通标签系统的模拟,普通标签系统可以运行一个通用图灵机的模拟。这不是完成通用计算的高效方式,但对这样一台简单的细胞自动机来讲仍然是一项令人印象深刻的技术成果。
我们要介绍的最后一个简单通用系统甚至比 rule 110 还简单:Wolfram 的 2,3 图灵机。它的名字源于其两个状态和三个字符(a、b 和空格) ,这意味着它只有 6 个规则:
Wolfram 的 2,3 图灵机看起来没有强大到能支持通用计算。2007 年,Wolfram Research 宣布将给予能证明它是通用的人 25 000 美元的奖励。那年下半年,Alex Smith 通过成功的证明拿到了这个奖。就像对 rule 110 一样,这个证明靠的是展示出这种机器可以模拟任何循环标签系统。这个证明还是非常详细的,在 http://www.wolframscience.com/prizes/tm23/ 可以看到全文。
]]>以下几点,不存在必然联系,也不针对任何人。
年轻人的第一份工作,特别重要!大多数应届生刚踏入职场时,仍是一张白纸。工作中,接触到的人,所做的事情,会慢慢深入到你的骨髓,不管是好的还是坏的。 工作久了就会发现,你的身上,或者你身边人身上总是能折射出当年第一个mentor 的影子。
职场中,一定会有两部分人,第一种是做事情的人,第二种是不做事情的人。孰好孰坏,并非总是非黑即白。萌新们一般会把事情分出个对错,因此,在遇到一些奇葩的人或者事情的时候不理解。
也许有些人会觉得自己的信念崩塌,不要急,也许换一个角度也许会豁然开朗:对错不重要,利益才是关键。古往今来,有钱道真语,无钱语不真。不信但看筵中酒,杯杯先劝有钱人;人为财死,鸟为食亡;如蚁附膻…这些词汇,道出了人的本性。但是,历史总是由那些掌握权势的人撰写。文明,社会的演进需要高瞻远瞩的人来推进,但历史却不总是。你不喜欢的,所不屑的,却主宰了历史的演进,而你只是参与。
对于做事情的人,得到权势,可以在权势的帮助下能做更多有意义的事情。不做事的人,得到权势,也不都是坏事,因为这样一般会激化矛盾,推进历史演进。
职场中,该如何选择?是站在利益这一边还是坚持自己的信念?这个选择或许每天都要遇到。争取利益这件事,无可厚非,但是一定不要丢掉自己的信念!不要太看重物质上的回报,既要坚持自己的信念,更要注意保护自己,不要陷入“党争”。
刚踏入社会,总是会变得现实起来。工资,户口,房子,车子… 这没错,只不过,不应该整天把这些东西作为自己奋斗的原动力。
读小学的时候,国家教育我们,要为中华民族之崛起而读书。踏入社会,不能沦落成以房子车子妹子孩子而工作。虽然现实是如此,我们所处的大环境和社会给与我们的压力,让我们必须要现实起来。但是啊,把目光放长远一点吧。视野一定要足够开阔,不要仅仅局限于现实,也要活在梦里,哪里这个梦多么不切实际,哪怕每天也是努力活着,但不要浑浑噩噩啊。
视野这个东西多重要呢?很难量化,但是可以反推一下做大事的人,视野都不是仅仅局限在自己的身上。
因此,一定要有大视野。这不是说一定要去心系天下,那总归也要有点理想吧。
说说如何定义一个好工作吧。
比如,有一个好 leader,他能潜移默化的影响到你几乎方方面面。待人接物,如何高效处理任务,安排任务,沟通等等。
反例,想想整天务虚吹逼,没有视野,喜爱算计,搞小团伙的 leader 是什么样子吧,不会用人,不关心团队发展,用职级打压下级…
一般情况下正确的事就是难的事情。因此,要学会离开自己的舒适区,敢想敢做,而不要划水,扯皮,搞 KPI 工程,吹嘘。
要开心,遇到不顺心的事情,要么改变自己,要么把问题解决。
开开心心的工作才能保证工作时间能够得到高效利用,否则一旦负面情绪占据了你的大脑,就无法全身心的做事情,这可真是浪费时间呐。
要不断提高自己,突破自己。下面主要针对技术实力。 还是那句话,基础和深度。可以通过阅读和配套实践来达到知行合一的效果,如果允许的话,那就要形成自己的观点,分享出去。
一定要多读外文经典书籍。比如 CSAPP, SICP, HTDP 等,而不是什么 XXX 从入门到精通。不是说这些书不好,是说这些书就是垃圾,误人子弟,会让你浪费太多的时间吸收一些本身没有什么营养的内容。
经典的书之所以经典一定要有他的道理,先不要急于反驳,读完我说的那三本书再来反驳!
举个简单例子,经典的书就像压缩过的能量棒一样,咬一口,比较难啃,但是即便是一小口,就能让你迅速快速补充能量。 无良抄袭国外书籍或者文档的作者,或者是误人子弟的那些作者的 XXX,就像是吃完能量棒后自己拉的屎,大部分营养已不在或者变味,而偏偏有些读者觉得真香!不过,吃过了,除了自己的消化系统损坏以外,也没什么营养。
所以,一定要有品位。知道如何搜索,如何获取第一手资料,而不是吃着像巧克力一样的屎。
还是关于布鲁姆认知领域六层次目标理论。阅读,理解后,实践完毕才能有信心说自己学会了。最好,还是分享出来。这个时候你就能带着自己的观点,有所评价,才意味着你对某个领域或者知识点是真正的有见地了。
特别喜欢这句话:“The difference between ordinary and extraordinary is that little extra”。也许上面这些胡言乱语就是这个小小的 extra 吧。
最后,勉励一下自己,不要无所作为!
]]>这将仍然仅仅是在用 Ruby 编程,但施加虚构的约束之后,我们便能很轻松地探索一个受限的语义,而不需要学习一门新语言。然后,我们了解到这些非常有限的特性集合能做什么以后,就将利用这些特性把它们实现为一种语言(使用它自己的解析器、抽象语法和操作语义)—— 使用我们在之前章节中学到的技术。
proc-> x { -> y { x.call(y) } }
写一个程序输出数字 1 到 100。但如果数字是 3 的倍数,就不输出数字而是输出“Fizz” , 如果是 5 的倍数就输出 “Buzz” 。 对于那些 3 和 5 的公倍数, 就输出 “FizzBuzz” 。
完整特性的 Ruby 实现:1
2
3
4
5
6
7
8
9
10(1..100).map do |n|
if (n % 15).zero?
'FizzBuzz'
elsif (n % 3).zero?
'Fizz'
elsif (n % 5).zero?
'Buzz'
else
n.to_s
end
描绘数字特征的一种方式是某个动作的重复(或者叫迭代)。
1 | def one(proc, x) |
1 | def two(proc, x) |
以此类推。
按照这种模式,可以很自然地把 #zero 定义为一个带有 proc 和另一个参数的方法,这个方法完全忽略 proc(换句话说,对其调用零次) ,并且会原封不动地返回第二个参数。
1 | def zero(proc, x) |
把 数 据 表 示 为 纯 代 码 的 技 术 称 为 邱 奇 编 码(Church encoding) , 它 是 以lambda 演算(http://dx.doi.org/10.2307/2371045)的发明者阿隆佐·邱奇的名字命名的。这些数字是邱奇数(Church numeral) ,而且我们很快将会看到邱奇布尔值(Church Boolean)和邱奇有序对(Church pair)的例子。
1 | def true(x, y) |
1 | IF = |
IS_ZERO = -> n { n[-> x { FALSE }][TRUE] }
最简单的数据结构是有序对(pair) ,它跟二元数组类似。有序对实现起来非常容易。
1 | PAIR = -> x { -> y { -> f { f[x][y] } } } |
现在有了数字,布尔值,条件,谓词以及有序对,下面实现数值运算。
递增:INCREMENT = -> n { -> p { -> x { p[n[p][x]] } } }
我们调用这个新的 proc 的时候它会做什么呢?首先它会以 p 和 x 作为参数调用 n——因为n 是一个数字,所以这意味着就像原始的数字那样, “在 x 上对 p 进行 n 次调用”——然后对结果再调用一次 p。那么总体说来,这个 proc 的第一个参数会在它的第二个参数上调用n+1 次,这恰好是表示数字 n+1 的方法。
递减呢?这看起来是个更难的问题。
一个解决办法就是设计一个 proc,在对某个初始参数调用 n 次的时候返回数字 n-1。幸运的是,有序对正好可以帮助我们实现这种方法。思考一下这个 Ruby 方法所做的:
1 | def slide(pair) |
在我们用数字组成的二元数组为参数调用 slide 时,它会返回一个新的二元数组,这个二元数组包含第二个数字还有比第二个数字大 1 的数字;如果输入的数组包含的是连续数字,那么效果就是向上“滑动”一个数字窗口:1
2
3
4>> slide([3, 4])
=> [4, 5]
>> slide([8, 9])
=> [9, 10]
这很有用,因为通过在 -1 处开始一个窗口,我们可以安排一种情况,让数组里的第一个数字比我们调用 slide 的次数小 1,即使我们只是在递增数据 :1
2
3
4
5
6
7
8>> slide([-1, 0])
=> [0, 1]
>> slide(slide([-1, 0]))
=> [1, 2]
>> slide(slide(slide([-1, 0])))
=> [2, 3]
>> slide(slide(slide(slide([-1, 0]))))
=> [3, 4]
我们不能只用基于 proc 的数字完成,因为没法表示 -1,但 side 的有趣之处是不管怎样它只关注数组中的第二个数,因此我们可以放入任意的哑值(dummy value)——比如说0——替换掉 -1,这样仍然能得到同样的结果:1
2
3
4
5
6
7
8>> slide([0, 0])
=> [0, 1]
>> slide(slide([0, 0]))
=> [1, 2]
>> slide(slide(slide([0, 0])))
=> [2, 3]
>> slide(slide(slide(slide([0, 0]))))
=> [3, 4]
这是让 DECREMENT 工作的关键:我们可以把 slide 转成一个 proc,使用数字 n 的 proc 表示对由 ZERO 组成的有序对调用 slide n 次,然后使用 LEFT 从结果的有序对中拉出左边的数来:
1 | SLIDE = -> p { PAIR[RIGHT[p]][INCREMENT[RIGHT[p]]] } |
注:DECREMENT[ZERO] 的结果实际上只是最初的 PAIR[ZERO][ZERO] 值的左边元素,在这种情况下根本就没有对其调用过 SLIDE。既然没有负值,0 就是我们能提供给 DECREMENT[ZERO] 的最合理的答案,因此使用 0 作为哑值是个好主意。
有了 INCREMENT 和 DECREMENT,就可能实现类似加法、减法、乘法和取幂这样的数字运算了:
1 | ADD = -> m { -> n { n[INCREMENT][m] } } |
MOD,map, range, 字符串都可以利用以上实现来实现,此处略。
]]>20 世纪 30 年代,阿兰·图灵(Alan Turing)致力于从本质上解决这个问题。在那个年代,单词 computer 意味着一个人,通常是一个女人,她手工重复着一系列繁重的数学性操作以执行长长的计算。图灵当时正在寻找一种理解和描述“人肉计算机”操作特征的方法,这样同样的工作就可以完全由机器执行。本章,我们将看到图灵关于设计最简单的“自动化机器”的思想,这一机器具有手工计算的全部能力和复杂性。
我们通过给一台有限自动机赋予一个作为外部存储的栈,增强了它的计算能力。与由机器状态提供的有限内部存储相比,栈的真正优点是能动态增长以适应任意数量的信息,从而使下推自动机能够处理那些需要存储任意数量数据的问题。
但是,外部存储这种特殊的形式给如何使用存储之后的数据带来了限制。通过把栈替换成更灵活的存储机制,我们可以消除这些限制并进一步提高能力。
计算通常可以通过在纸上写某些符号完成。我们可以把这张纸想象成小朋友的算术本,它被划分成了一个个方格。在初等算术里,我们有时也会使用纸的二维特性。但这种使用通常是可以避免的,并且我认为纸的二维性不是计算的本质,而且相信大家也赞同我这一观点。我假定计算是在一张一维的纸上完成的,比如在一条分成方格的纸带上完成。 ——阿兰•图灵, 《论可计算数及其在判定性问题上的应用》
图灵的做法是给一台机器配上一条无限长的空纸带(实际上是一个两端都能随需增长的一维数组) ,并且允许在纸带上的任意位置读写字符。一条纸带既做存储又做输入:可以在纸带上预先填满字符串当作输入,然后机器在执行过程中可以读取这些字符并在必要的时候覆盖它们。
能访问一条无限长纸带的有限状态自动机叫作图灵机(Turing Machine,TM ) 。这个名字通常指一条拥有确定性规则的机器,但我们也可以毫无歧义地叫它确定型图灵机(Deterministic Turing Machine,DTM) 。
传统的图灵机使用简单的安排:用一个纸带头(tape head)指向纸带的一个特定位置,并且只能在那个位置读取或写入字符。每一步计算之后,纸带头都可以向左或者向右移动一个方格,这意味着一台图灵机为了到达远处的位置只能费力地在纸带上往复移动。使用移动缓慢的纸带头不会影响机器访问纸带上任何数据的能力,只会影响花费的时间,因此为了保持简单付出这个代价是值得的。
能访问纸带之后,除了能够接受或者拒绝字符串,我们又能解决新的问题了。例如,我们可以设计一台在纸带上就地递增一个二进制数的 DTM。为此,我们需要知道如何递增一个二进制数的一位数字。幸好这很简单:如果这位的数字是 0,就用 1 替换;如果这位数是 1,就用 0 替换,然后立即使用同样的方法增加它左边的数字( “进 1 位” ) 。图灵机只需要使用这个过程递增二进制数的最右位,然后把纸带头移到起始位置。
在机器试图递增一位数字的时候,它处于状态 1,在移回起始位置时处于状态 2,结束的时候处于状态 3。下面是初始纸带上字符串为 ‘1011’ 时对机器执行的跟踪。纸带头当前指向的字符会由括号包围,而下划线表示输入字符串某一端的空白方格。
让我们想象一下,由机器执行的操作被分解成“简单的操作” ,这些操作都非常基本,以至于无法想象它们能进一步分解。……操作实际上是由计算者的思维状态和被观察的符号决定的……具体来讲,操作执行之后,计算者的思维状态就确定了。
我们现在可以构造一台做这种计算者工作的机器了。 ——阿兰•图灵, 《论可计算数及其在判定性问题上的应用》
在每一步计算中,可能都有几个“简单的操作”需要图灵机执行:在纸带头的当前位置读取字符,在那个位置写入一个新字符,把纸带头左移或者右移,或者改变状态。简单起见,我们没有为所有这些动作指定不同种类的规则,而只是像处理下推自动机时那样,只设计了一种能灵活适应各种条件的规则格式。
这个统一的规则格式有 5 部分:
这里我们假设一台图灵机每次执行规则,都要改变状态并向纸带写一个字符。就像通常对状态机的处理那样,如果我们想要一个规则不实际改变状态,可以让“下一个状态”与当前状态相同;与之类似的是,如果想要一个规则不改变纸带内容,可以把与读到的字符一样的字符写入纸带。
我们看到非确定性没有让有限自动机有什么不同。增加不确定性不会使一台图灵机更加强大。因为 DFA 和 DTM 都有足够的能力模拟其非确定性的对应机器。
确定型图灵机代表了从有限计算机器到全能机器的临界点。实际上,通过升级图灵机规范以使其更强大的任何尝试都注定失败,因为它们本来就有能力模拟任何潜在的增强了。尽管增加某些特性会使图灵机更小巧或者更高效,但无法从根本上增强它们的能力。
例如对传统图灵机的 4 个其他扩展——内部存储、子例程、多纸带以及多维纸带,没有任何一个可以增加计算能力。
尽管到目前为止我们看到的机器都有严重的缺陷:它们的规则都是硬编码的,这让它们无法适应不同的任务。一台能接受与一个特定正则表达式匹配的字符串的 DFA,不可能学会接受一个不同集合的字符串;一台能识别回文的 NPDA 将只能识别回文;一台递增二进制数的图灵机将永远不能做其他用途。
大多数现实中的计算机不是这么工作的。现代计算机不是专门做某一项特殊工作的,而是为了通用目的而设计的并且能通过编程执行不同的任务。尽管一台可编程计算机的指令集和 CPU 设计是固定的,但能通过软件控制它的硬件并根据用户需要改变它的行为。
我们的简单机器能做这样的事情吗?在做一件不同的工作时,不必每次去设计一台新的机器,而是设计一台简单机器,它会从输入读取一个程序,然后做这个程序定义的任何工作。这办得到吗?
或许不足为奇的是,一台图灵机足够强大,它能从纸带读取一台简单机器的描述——比如说,一台确定性有限自动机——然后运行这台机器的模拟以找出它的工作内容。
一台完整的图灵机以字符串的形式写在另一台图灵机的纸带上,准备通过模拟开始自己的生命周期。
它的规则手册、接受状态以及起始配置——都以编码的格式存在于UTM 的纸带上。为了执行模拟的一步,UTM 要在规则、当前状态和所模拟机器的纸带之间来回移动纸带头,以搜索出能应用到当前配置的一条规则。它找到一条规则的时候,就会根据规则里定义的字符和方向,更新所模拟的纸带,并把所模拟的机器放到新的状态上去。
这个过程会一直重复,直到所模拟的机器进入到一个接受状态,或者到达某个配置后因为没有规则应用处于卡死的状态。
]]>我们没法通过为有限自动机增加非确定性和自由移动这种奇特的特性来提高它的计算能力。
这个事实表明,我们已经停留在这些简单机器的计算水平上无法前进了。而且如果不从根本上改变机器的工作方式,将无法脱离这种停滞不前的境地。那么,所有这些机器到底有多强的能力呢?好吧,没有多少能力。它们被限制在非常有限的应用上(只能接受或者拒绝字符序列) ,而且即使在这么小的范围内,仍然很容易碰到机器无法识别的语言。
举个例子,假设要设计一台有限自动机,要求它能读取带有左右括号的字符串,并且只有字符串中的左右括号是平衡的(即每一个右括号都能在字符串中找到与其匹配的左括号) ,它才会接受。
可以设计如下 NFA 来实现:
一次读取一个字符,同时跟踪一个表示当前嵌套级别的数字:读入一个左括号时增加嵌套级别,读入一个右括号时降低嵌套级别。只要嵌套级别到零了,就表示当前读到的这些括号已经都匹配上了(因为嵌套级别增加和减少的数量是一样的) ,并且如果我们试图把嵌套级别降低到小于零的值,那就表明当前的右括号多了(如 ‘())’) ,不管还有什么字符没有读取,字符串里的括号一定已经不平衡了。
可是这种设计有一个严重的缺陷:如果括号的嵌套等级超过 3,它就会失败。它没有足够多的状态跟踪 ‘(((())))’ 这样的字符串的嵌套,因此即使括号明显是平衡的它也会拒绝。
我们可以通过临时增加更多的状态来修正此问题。一台拥有 5 个状态的 NFA 可以识别任意嵌套级别小于 5 的平衡字符串,而一台拥有 10 个、100 个或者 1000 个状态的 NFA,可以识别嵌套级别在机器硬限制以内的任意平衡字符串。但是,我们如何设计支持任意嵌套级别、能识别任意平衡字符串的 NFA 呢?结论是设计不出来:一台有限自动机的状态数总是有限的,因此任何机器能支持的嵌套级别也总是有限的,我们只要提供一个比它能处理的嵌套级别多一级的字符串,它就无法处理了。
根本问题是一台有限自动机只有固定的状态集合,因而其存储是有限的,因此没法跟踪任意数量的信息。在平衡字符串问题当中,一台 NFA 很容易递增到设计时限制的某个最大数目,但无法继续计数以适应任何可能大小的输入。
本质上大小固定的任务(比如对字符串 ‘abc’ 进行匹配) ,或者无需跟踪重复次数的任务(比如对正则表达式 ab*c 进行匹配) ,都不受这个问题的影响,但在信息数目不可预知,需要在计算过程中存储并在之后重用的场景下,这个问题会让有限自动机无能为力。
为了解决存储问题,我们可以使用专门的原始空间扩展有限状态自动机,它负责在计算过程中存储数据。除状态提供的有限内部存储之外,这个空间给了机器一种外部存储(external memory) 。就像我们将会发现的那样,拥有外部存储对于一台机器的计算能力关系重大。
为有限自动机增加存储的简单方式就是让它可以访问栈,这是一个后进先出的数据结构,可以把字符推入和弹出。栈是简单而且有限制的数据结构——在任意时间都只有顶端的字符可以访问。为了查明栈下面位置的数据,我们只能丢弃顶层的字符,而一旦向栈内推入一串字符,我们就只能按相反的顺序把它们弹出——但它确实可以很好地解决有限存储的问题。对于栈的大小并没有内在的限制,因此原则上它可以根据需要存储数据。
自带栈的有限状态机叫作下推自动机(PushDown Automaton,PDA) ,如果这台机器的规则是确定性的,我们就叫它确定性下推自动机(Deterministic PushDown Automaton,DPDA)。
能对栈进行访问带来了新的可能性,例如,很容易设计一台 DPDA 来识别括号组成的平衡字符串。
尽管处理平衡括号问题的机器确实需要栈来完成工作,但它其实只是将栈作为一个计数器,并且它的规则只区分“栈为空”和“栈不为空” 。更复杂的 DPDA 将会把一种以上的符号推入栈中,并在执行计算时使用这些信息。一个简单的例子是一台机器,它能识别包含相等数目的两种字符的字符串,比如 a 和 b。
DPDA 没有利用栈的全部优点。为了真正开发出栈的潜能,我们需要一个更难的问题强迫我们存储结构化信息。经典的例子是识别回文字符串:随着一个字符一个字符地读取输入字符串,我们需要记住所看到的数据;一旦字符串读取过了一半,就要检查内存以确定之前看到的字符是否为当前呈现字符的逆序。下面这个 DPDA 能够识别一个回文字符串,这个字符串由字符 a 和 b 组成,并且在中间的位置有一个字符 m(表示中间位置) :
这台机器从状态 1 开始,不断从输入读取 a 和 b,然后把它们推入栈中。它读到 m 的时候,会转移到状态 2,在那里一直读取输入字符同时尝试把每一个字符都弹出栈。如果字符串后半部分的每一个字符都与栈中弹出的内容匹配,机器就停留在状态 2 并最终碰到栈底的 $,此时转移到状态 3 并接受这个输入字符串。处于状态 2 的时候,如果读入的任何字符与栈顶的字符不匹配,那就没有规则可以遵守,因此它将进入阻塞状态并拒绝这个字符串。
没有确定性约束的下推自动机叫作非确定性下推自动机(nondeterministic pushdown automaton) 。下面是一台能识别由偶数个字母组成的回文字符串的非确定性下推自动机:
下推自动机有一个重要的实际应用:它们能用来解析编程语言。传统的方式是把解析过程分成两个独立的阶段:
词法分析
读取一个原始字符串然后把它转换成一个单词 token 序列。每一个单词 token 代表程序语法的一个组成部分,例如“变量名” 、 “左括号”或者“while 关键字” 。词法分析器使用称为词法的规则集合来决定什么样的字符应该产生什么样的单词。这个阶段处理杂乱的字符级别的细节,比如变量命名规则、注释和空格,它为下一阶段的处理准备好清楚的单词序列。
语法分析
读入一个单词序列并根据正在分析的语言语法判断它们是否代表一个有效的程序。如果程序有效,那么语法解析器会生成一些关于程序结构的附加信息(如一个解析树)。
词法分析阶段通常相当直接。这可以通过正则表达式实现(因而也就是通过一台 NFA 实现) ,因为它把字符序列与一些规则简单匹配以判断那些字符是否为关键字、变量名、运算符或者其他什么符号。
使用下推自动机是可以识别单词的有效序列。
分析的过程依赖于非确定性,但在实际程序中,最好能避免非确定性,因为一个确定性的 PDA 模拟起来要比非确定性的快得多而且容易得多。幸运的是,在每个阶段几乎都可以使用输入单词本身决定该应用哪个符号规则,这样就可能把非确定性去掉——这个技术叫作递推(lookahead)——但这让从 CFG 到 PDA 的转换更为复杂。
在实践中,我们可以让 PDA 模拟记录它到达接受状态过程中的规则序列,以此来创建结构化表示,这个规则序列提供了构建一个分析树所需的足够信息。
我们见到了两个新的计算能力的级别:DPDA 比 DFA 和 NFA 更强大,而NPDA 要比 DPDA 更强大。能访问栈之后,看起来下推自动机比有限自动机要强大和复杂一些。
拥有栈的主要结果就是能识别某些有限自动机不能识别的语言了,如回文和平衡括号字符串。栈提供的无限存储使 PDA 能在计算中记住任意数量的信息并在随后再次使用它。
PDA 能识别回文,但它不能识别 ‘abab’ 和 ‘baaabaaa’ 这样“双倍”的字符串,因为一旦信息被推入到栈中,就只能以相反的顺序处理了。
如果我们抛开识别字符串的特定问题,而把这些机器看成通用目的的计算机,就可以看到DFA、NFA 和 PDA 还远远不够有用。首先,它们都没有像样的输出机制:它们通过进入接受状态表达成功,但不能输出哪怕一个字符(更不用说由字符组成的字符串了)来表示详细的结果。无法将信息发送回世界意味着它们连把两个数相加这样的简单算法都实现不了。而像有限自动机一样,PDA 有一个固定的程序;没有明显的方法构建出一台 PDA 能以某种方式从输入读取一个程序然后运行。
所有这些缺点意味着我们需要一个更好的计算模型,去真正地研究计算机能干什么。
]]>假设我们想要一台有限自动机,它能接受由 a 和 b 组成的第三个字符是 b 的任意字符串。此时很容易想出一个合适的 DFA 设计:
如果想要一台机器能接受倒数第三个字符是 b 的字符串,怎么办呢?那将如何工作呢?似乎更加困难:上面的 DFA 能保证在读第三个字符的时候处于状态 3,但是一台机器无法预先知道什么时候能读到倒数第三个字符,因为在结束读取之前它不知道这个字符串有多长。甚至这样的一台 DFA 是否可能存在都不一定能立刻清楚。
但是,如果我们放松确定性的限制,并且允许规则手册对于一个状态和输入包含多条规则(或者根本没有规则) ,那么就可以设计一台能完成任务的机器:
这是一台非确定性有限自动机(NFA) ,对每一个输入序列不再只有一条执行路径。处于状态 1 并且读入 b 的时候,它可能会按照一条规则仍保持在状态 1,但也可能会按照另一条规则进入状态 2。反过来,一旦进入状态 4,它找不到任何规则可以遵守,因此没法再继续读取输入。一台 DFA 的下一状态总是完全由它的当前状态和输入决定,但是一台NFA 在向下一个状态转移时会有多种可能性,而且有时候根本无法转移。
如果一台 DFA 读取一个字符串然后完全按照规则执行,并且最终终止于一个接受状态,那它就能接受这个字符串。那么对于一台 NFA 来说,什么才能表示一台 NFA 接受或者拒绝一个字符串呢?很自然的回答是,如果存在某条路径能让 NFA 按照它的某些规则执行并终止于一个接受状态,那它就能接受这个字符串 ; 这就是说,即使不是必然的,只要终止于一个接受状态是可能的就可以。
例如,这台 NFA 接受字符串 ‘baa’,因为从状态 1 开始,有一条路径可以让这台机器读取一个 b 转移到状态 2,再读取一个 a 转移到状态 3,最后读一个 a 终止于状态 4,这是一个接受态。它还接受字符串 ‘bbbbb’,因为 NFA 可以在读取前两个 b 的时候,按照另一条规则执行并停留在状态 1,然后在读第三个 b 的时候使用规则转移到状态 2,再读取字符串的其他部分,并向以前那样终止于状态 4。
另一方面,没有读取 ‘abb’ 并终止于状态 4 的方法(取决于遵照的不同规则,它最终只能终止于状态 1、2 或者 3) ,因此这台 NFA 不接受 ‘abb’。’bbabb’ 也不行,它最多只能到达状态 3:如果读入第一个 b 的时候直接转移到状态 2,它将很快终止于状态 4,这样留下两个字符没有处理但是已经没有规则可用了。
能被一台特定机器接受的字符串集合称为一种语言:我们说这台机器识别了这种语言。不是所有的语言都有一台 DFA 或者 NFA 能识别它们(详见第 4 章) ,但那些能被有限自动机识别的语言称为正则语言(regular language) 。
在确定性计算机上模拟一台 NFA,关键是找到一种方法探索出这台机器所有可能的执行。
这种暴力方法把所有的可能全都摆出来,以此避免了只模拟一种可能执行时所需要的“幽灵般”的预见性。一台 NFA 读到一个字符的时候,它下一步转移到什么状态只会有有限数目的可能性,因此我们模拟非确定性时可以尝试遍历所有可能,然后看它们中哪个最终到达一个接受状态。
尝试遍历所有可能时可以采用递归的方式:每当所模拟的 NFA 读取一个字符并且有多个可用的规则时,遵照其中的一条规则,然后尝试读取输入的后续部分;如果这没有让机器到达一个可接受状态,就回退到早期状态,把输入也倒回早期的位置,然后按照另一个不同的规则再次尝试;如此重复,直到某次选择的规则让机器到达一个接受状态,或者所有可能的选择进行遍历的结果都不成功为止。
还有一个策略是采用并行的方式模拟所有可能:每当机器有超过一条规则可以遵守时就创建新线程,并把需要模拟的 NFA 复制过去以便复制的每一份都能尝试一条新规则,然后观察它的结果。所有这些线程都能同时执行,每个都从它自己的输入字符串副本中读取。
如果任何一个线程让机器读取了整个字符串,并且停止于一个接受状态,那么可以说这个字符串已经被接受了。
很容易设计一台 DFA,能接受长度是 2 的倍数的、由字符 a 组成的字符串(’aa’、’aaaa’……) :
但是如何设计一台机器,让它能接受长度是 2 或 3 的倍数的字符串呢?我们知道非确定性让一台机器可以走多于一条的执行路径,因此或许可以设计一台 NFA,它有一条“2 的倍数”的路径和一条“3 的倍数”的路径。一个初步的尝试可能看起来像这个样子:
这台 NFA 的思想是,在状态 1 和状态 2 之间移动以接受像 ‘aa’ 和 ‘aaaa’ 这样的字符串,在状态 1、状态 3 和状态 4 之间移动以接受像 ‘aaa’ 和 ‘aaaaaaaaa’ 这样的字符串。这工作得很好,但问题是这台机器还会接受字符串 ‘aaaaa’,因为它可以从状态 1 转移到状态 2 然后读完前两个字符的时候回到状态 1,再在状态 3 和状态 4 之间转移,之后在读完接下来的三个字符之后回到状态 1,终止于一个接受状态,即使这个字符串的长度不是 2 或者3 的倍数。
这次,一台 NFA 是否能完成这个工作还不是很明显,但是我们可以引入一个叫作自由移动的机器特性来解决此问题。这些规则让机器无需读取任何输入就能自发遵照执行,并且它们在这儿提供帮助是因为能让 NFA 在两组状态之间做一个初步选择:
自由移动表示成从状态 1 到状态 2 和状态 4 的无标记虚线箭头。机器仍然接受字符串’aaaa’,它会先自发地转移到状态 2,然后随着读取输入在状态 2 和状态 3 之间转移。类似地,如果它开始先自由移动到状态 4 也能接受 ‘aaaaaaaaa’。但是现在它没法接受字符串 ‘aaaaa’ 了:不管做任何可能的执行,它都一定要从到状态 2 或者状态 4 的转移开始,而且一旦选择了其中一条路径转移之后,就没法退回来了。一旦处于状态 2,就只能接受一个长度是 2 的倍数的字符串,同样一旦处于状态 4,就只能接受长度是 3 的倍数的字 符串。
非确定性和自由移动增强了有限自动机的表达能力。
有限自动机完全适合这个工作。就像我们即将看到的,把任何正则表达式转成一个等价的 NFA 是可能的——每一个与正则表达式匹配的字符串都能被这台 NFA 接受,反过来也一样——把字符串输入给一台模拟的 NFA 看它是否能被接受,从而判断字符串是否与正则表达式匹配。用第 2 章的话说,我们可以把这个看成是为正则表达式提供了一种指称语义:我们不一定知道如何直接执行一个正则表达式,但是可以展示如何把它表示成一台NFA,并且因为有了 NFA 的操作语义( “通过读取字符然后执行规则改变状态” ) ,所以可以执行这个指称(denotation)实现同样的结果。
把任何非确定性有限自动机转成接受完全相同字符串的确定性自动机是可能的。
非确定性和自由移动只是一台 DFA 已经能做的工作的再包装,就像编程语言里中的语法糖一样,它们不是让我们超越确定性约束的新能力。
理论上说,为一台简单的机器增加更多的特性却没有为它根本上增加更多的能力非常有趣,但实际上这是很有用的,因为一台 DFA 比一台 NFA 更容易模拟:只有一个当前状态要跟踪,并且一台 DFA 用硬件或者机器代码实现起来足够简单,可以使用程序存储位置作为状态,用条件分支作为规则。这意味着一个正则表达式的实现可以把一个模式先转换成一台 NFA 然后再转换成一台 DFA,得到一台能被快速高效模拟的非常简单的机器。
]]>下面是一台有限自动机的结构图:
两个圆代表自动机的两个状态 1 和 2。凭空出现的箭头表明这台自动机从状态 1 开始,1 是它的起始状态。两个状态之间的箭头代表机器的规则:
这让我们有足够的信息研究机器如何处理一个输入流。
一旦回到状态 1,它又将开始重复自身,这就是这台机器的行为范围。我们可以认为当前状态的信息存在于机器内部, 它像一个“黑盒”一样运转,并不会展现其内部工作状况——这台无聊的机器毫无用处,没有任何能观察到的输出。即使这台机器一直在状态 1 和状态 2 之间切换,机器之外也没有一个人能看出来有什么事情在发生。因此在这种情况下,我们可能还要增加一个状态,这样就不用再为任何内部结构操心了。
备注:每台有限自动机没有通用的 CPU 执行任意程序,而是硬编码了一些规则集合,以决定在相应的输入下如何从一个状态切换到另一个状态。自动机先从一个特定的状态开始,然后从输入流中读入字符——按照规则它每次读取一个字符,有限自动机没有键盘、鼠标和接收输入的网络接口,只有一个外部的字符输入流可以一次读取一个字符。
只是把一些状态标记成特别状态,并且认为机器的单比特输出提供了当前是否处于特别状态的信息。对于这台机器,我们将状态 2 作为特别状态,并在图中用双重的圆形表示它。
这些特定状态通常称为接受状态,表明这台机器对某个输入序列是接受还是拒绝。如果这台自动机从状态 1 开始并读入一个 a,它将会停留在状态 2,这是一个接受状态,因此我们可以说这台机器接受字符串 ‘a’。另外,如果它先读到一个 a,然后又读取了另一个 a,它将终止于状态 1,这不是一个接受状态,所以这台机器拒绝字符串 ‘aa’。事实上很容易看到,这台机器接受任何奇数个数的 a 组成的字符串:’a’、’aaa’、’aaaaa’ 都能被接受,但是 ‘aa’、’aaaa’ 和 ‘’(空字符串)会被拒绝。
很明显,这种自动机具有确定性:不管它当前处于什么状态,并且不管读入什么字符,最终所处的状态总是完全确定的。只要满足下面两个约束,就能保证这种确定性。
没有冲突
不存在这样的状态: 它的下一次转换状态因为有彼此冲突的规则而有二义性。
(这意味着一个状态对于同样的输入,不能有多个规则。 )
没有遗漏
不存在这样的状态:它的下一次转换状态因为缺失规则而未知。 (这意味着每个状态都必须针对每个可能的输入字符有至少一个规则。 )
综上,如果遵循上述约定,这就是一台确定性有限自动机(DFA)。
]]>《计算机的本质:深入剖析程序和计算机》这本书涵盖了计算理论和编程语言设计,阐释了形式语义、自动机理论,以及通过 lambda 演算进行函数式编程等计算问题,可以帮助更好的理解计算机科学和计算原理。
本文主要是阅读过程中的一些摘录和整理。
传统的计算机程序是长长的字符串。每一种编程语言都有一系列规则,描述在那种语言中什么样的字符串被认为是有效程序。这些规则定义了这种语言的语法。
语法关心的只是程序的表面是什么样的,而不是它的含义。程序有可能语法正确但没有任何实际意义。例如,程序 y = x + 1 本身可能没有任何意义,因为并没有事先说明 x 是什么,而程序 z = true + 1 可能会在运行时候报错,因为它试图在一个布尔型值上加数字。
(当然,这依赖于具体编程语言的其他属性。 )
操作语义学(operational semantic)的基础,这种方法为程序在某种机器上的执行定义一些规则,以此来捕捉编程语言的含义。这个机器常常是一种抽象的机器:为了解释这种语言所写的程序如何执行而设计出来的一个想象的、理想化的计算机。为了更好地捕获编程语言的运行时行为,通常需要针对不同种类的编程语言设计不同的抽象机器。
假想一台机器,用这台机器直接按照这种语言的语法进行操作一小步一小步地对其进行反复规约,从而对一个程序求值。不管最后得到的结果含义是什么,我们每一步都能让程序更接近最终结果。
这种小步规约类似于对代数式求值的方式。
例如,为了对 (1×2) + (3×4) 求值,我们知道应该:(1) 执行左侧的乘法(1×2 变成了 2) ,这样表达式就规约成了 2 + (3×4);(2) 执行右侧的乘法(3×4 变成了 12) ,这样表达式规约成了 2 + 12;(3) 执行加法(2 + 12 变成了 14) ,最终得到 14。
大步语义的思想是,定义如何从一个表达式或者语句直接得到它的结果。这必然需要把程序的执行当成一个递归的而不是迭代的过程:大步语义说的是,为了对一个更大的表达式求值,我们要对所有比它小的子表达式求值,然后把结果结合起来得到最终答案。
大步语义经常会写成更为松散的形式,只会说哪些子计算会执行,而不会指明它们按什么顺序执行。
指称语义(denotational semantic)关心从程序本来的语言到其他表示的转换。
指称语义确实是一种比操作语义更抽象的方法,因为它只是用一种语言替换另一种语言,而不是把一种语言转换成真实的行为。
形式化的语义通常都是由数学化的工具完成的。
形式化语义的一个重要应用是为一种编程语言的含义给出一个无歧义的定义,而不是让其依赖于像自然语言规范文档和“由实现规范”这样更加随意的方法。形式化的定义还有其他用途,例如证明某种语言通常情况下的特性,以及特定程序在特定情况下的特性,证明语言中程序之间的等价性,研究如何在不改变程序行为的情况下安全地变换程序而使其效率更高。
例如,既然操作语义与解释器的实现极为接近,那么计算机科学家就可以把一个适当的解释器看成一种语言的操作语义,然后证明它在那种语言的指称语义方面的正确性——这意味着证明了由解释器给出的含义和由指称语义给出的含义之间存在着明显的联系。
指称语义的一个优点是比操作语义抽象层次更高,它忽略了程序如何执行的细节,而只关心如何把它转换成一个不同的表示。例如,如果存在一种指称语义可以把两种语言翻译成某种共通的表示,就使对不同语言写成的两个程序进行比较成为可能。
抽象程度会使指称语义看起来有点兜圈子。如果问题是如何解释一种程序设计语言的含义,那么把一种语言翻译成另一种语言是如何让我们更接近问题答案的呢?一个指称只不过与它的含义一样好;尤其是,如果指称的语言有某种操作性的含义,那么一个指称语义只是让我们更接近于能实际执行一个程序,这个语言的语义本身展示了它是如何执行的,而不是如何翻译成另一种语言的。
形式化的指称语义使用抽象的数学对象(通常是函数)来表示表达式和语句这样的编程语言结构,并且因为数学上的约定会规定如何对函数求值这样的事情,这就有了一种直接在操作意义上思考指称的方式。我们已经使用了不太正式的方式,把指称语义看成是一种语言到另一种语言的编译器,而事实上这是多数编程语言最终得以执行的方式:一个 Java 程序将会由 javac 编译成字节码,字节码将会被 java 的虚拟机即时编译成 x86 的指令,然后一个 CPU 会把每一条 x86 指令解码成类 RISC(精简指令集)的微指令放到一个核上去执行
treetop 是本书中推荐的一个 ruby 的语法解释器。
偷偷的放一下本书的资源吧,点我下载, 侵权删。
]]>起止时间:2018.12.19 ~ 2019.3.29
具体定义请看百科。这里给出自己的理解,该理论简化而言就是认知过程过程可分为三大层级,或者是三个步骤,逐级加深。
首先是通过阅读一手,或者 N 手材料,消化并且理解了某个知识点,举例而言,通过 Python 官方文档学会了基本语法,该阶段的特点是吸收的快,忘掉的也快,这个跟理解的深度有关系,对事物理解的越透彻,越是能看清本质,比较容易加深印象,不容易忘记。
第二阶段,能运用,关键是动手实践,所谓知行合一,比如学会了 Python 的语法,理解了该语言编程范式,可以用它来做一些项目,中间必然会遇到一些问题,可能会反复查阅文档,解决,如此往复可能需要一段时间才能熟练运用。
第三阶段,分享。经过动手实践,仅仅能代表自己掌握了该知识点并作为自己的一项技能来熟练运用,而大多数人往往止步于此。同时,分享是更高层级的认知活动。举例,有人邀请你做一场关于 Python 的分享活动,那么你自然会站在更高的层级来看待这个问题,比如你会有自己的评判(Python 是不是世界上最好的编程原因),一些奇淫技巧,或者是某些最佳实践,这些都可以认为是在实践过程中,对之前掌握的知识点的一个提炼。分享的同时也是在创造价值。
一般而言,掌握一项技能,大概需要 3 个月。中间可能会有不可抗拒的外力,多出来的 10 天作为 Buffer。
分享是更高层级的认知活动。如果一个毫无经验的人可以从你的分享中受益,首先你必须真正掌握了改知识点以及相关外围知识,其次你的表达能力也会得到锻炼。而表达,很重要!(有多少人,自认为自己很熟悉某个知识,但无法表达出来!!!)。
每个人都不一样。阅读和分享的内容固然重要,但是学习知识的过程可能更重要。授人以鱼不如授人以渔。希望这 100 天不仅可以让自己真正学会某些东西,也能让自己的学习能力得到锻炼,争取成为一个高效的学习者。
可能坚持 10 天,20 天,拍脑袋想,没那么难,但是 100 天绝对是一个巨大的挑战,不要盲目自信,拭目以待吧。
本文主要记录了 OLLVM 的编译,使用流程。
obfuscator 在 2010年由 HEIG-VD 发起,为 LLVM 提供了代码混淆编译组件。Obfuscator 作用在 LLVM 的 IR 层,因此,它和其他所有可经由 LLVM 编译的语言都兼容,比如 C,C++, OC, Ada 等,并且也支持所有的平台(x86, x86-64, PowerPC, PowerPC-64, ARM, Thumb, SPARC, Alpha, CellSPU, MIPS, MSP430, SystemZ, and XCore)。
obfuscator 有多个分支,我们选 llvm-4.0, 它基于 LLVM 官方版本 4.0.1。
编译 obfuscator 需要安装 CMake,克隆代码需要 git, 读者自行补齐。
只需要执行如下系列命令:1
2
3
4
5
6$ git clone -b llvm-4.0 https://github.com/obfuscator-llvm/obfuscator.git
$ cd obfuscator
$ mkdir build
$ cd build
$ cmake -DCMAKE_BUILD_TYPE=Release ..
$ make -j7
大概需要个个把小时,就会有结果。编译结果的二进制文件在 obfuscator/build/bin 目录下,比如 clang 命令。
假设我们有源文件 cff.c, 路径位于相对于源码根目录 ./obfuscator 下:1
2
3
4
5
6
7
8int main(int argc, char** argv) {
int a = 0;
if(a == 0)
return 1;
else
return 10;
return 0;
}
我们先用系统自带的 clang 编译,产生结果文件(在 cff.c 源码路径):$ clang cff.c -o original
然后我们使用自己编译的 clang + ‘Control Flow Flattening’ 模式混淆。
在编译结果 bin 路径执行如下命令:./clang-4.0 ../../obfuscator/cff.c -o ../../obfuscator/obs -mllvm -fla
然后我们用 Hopper 分别打开 original 和 obs 两个对象文件:
对比发现,经过 cfa 混淆后,原本程序的控制流被打破,增加了阅读门槛。
本质上指令替换就是用等价的更加复杂的指令替换原本可读性更好的指令。比如,加减以及布尔指令。
编译时,使用 -mllvm -sub 参数即可。
该模式改变原本程序的控制流。
例如在 _使用_ 示例,原本程序的控制流如下:
经过控制流平展后,控制流变成:
编译时使用 -mllvm -fla 参数。
也是对程序的控制流做操作,不同的是,BCF模式会在原代码块的前后随机插入新的代码块,新插入的代码块不是确定的,然后新代码块再通过条件判断跳转到原代码块中。更要命地是,原代码块可能会被克隆并插入随机的垃圾指令。
编译时加 -mllvm -bcf 参数即可。
注意该模式 obfuscator 没有实现,这里只是提及一下,也是一种常见的混淆模式。混淆后的字符串没办法直接搜索到,变成一系列操作后的合成产物,提高了反编译成本。
有的时候,由于效率或其他原因的考虑,我们只想给指定的函数混淆或不混淆该函数,OLLVM也提供了对这一特性的支持,你只需要给对应的函数添加attributes即可。比如,想对函数foo()使用fla混淆,只需要给函数foo()增加fla属性即可。
例如:
1 | int foo() __attribute((__annotate__(("fla")))); |
Android 项目中的 WebView 集成了腾讯的 X5 内核,由于 X5 在展示方面的兼容性问题深受前端们的喜爱(我能说,他们的 H5 页面的样式兼容问题,全部推锅到移动开发吗, 除了最新版本的 Chrome 内核,是否可以考虑下别的浏览器…)。
然而,就在今天,一个不幸的上午,捕获到了如下异常:
1 | UncaughtException detected: android.os.FileUriExposedException: file:///storage/emulated/0/DCIM/Camera/1536649175379.jpg exposed beyond app through ClipData.Item.getUri() |
幸运的是找到了出现问题的调用: <input type="file" acctType="image/*"/>
。
上述标签,最终会调用到 WebChromeClient 的 onShowFileChooser
方法(不同 Android 版本有所差异,>= 5.0 是此方法),然而,经过测试,这次崩溃并没有调用到此处(一脸懵逼)。
乍一看是跨 APP 文件共享导致的问题,恰好最近项目刚把 targetSdkVersion
从 22 升级到 26,也就是必须要兼容 Android 6.0 的动态权限以及 7.0 的跨应用文件共享。
但是,仔细分析堆栈信息,没有发现项目主动调用的代码,可疑点锁定到这一行 at com.tencent.tbs.core.partner.b.a$2.onClick(Unknown Source:406)
。但是下载下来的
腾讯内核 jar 文件中并没有包含 com.tencent.tbs.core.partner.**
,由于 X5 内核只提供了轻量级的 jar 文件,实际内核的下载和更新是 APP 安装后动态进行的,于是乎去了
/data/data/{packageUd}/app_tbs
, 并且把所有文件都导到电脑上,里面主要包含了包含资源文件的 apk, .so 文件以及一个 dex 文件,经过 dex2jar 这些操作后,仍然没有找到可疑的类文件。
上一条路被堵死。
很幸运,恰好今天帮测试解决 UI Automator
的问题,顺道也用了一下, 获得如下布局:
emmm, 出问题的关键就是点击拍照,项目中没有主动显示这种样式的弹窗,因此可能是系统做了拦截处理,或者就是 X5。
换了几台不同厂商的测试机实验,样式都是一样,基本排除是系统拦截处理的锅。最可疑的就是 X5 了。
中午还去了浜烧市场吃了顿饭,心好大….
基本锁定问题后,就开始各种预先申请权限,StrictMode 上折腾,试图解决权限问题,无果。
但每次 APP 崩溃几次后,再次调用,发现又会调用到 WebChromeClient 的 onShowFileChooser
方法,由于我们自己做过权限处理,一切又恢复正常。
(后来发现是 X5 发现崩溃后,降级逻辑)。
测试发现,出问题的点只是拍照这一个地方,可恶的腾讯 X5 内核并没有做兼容 7.0 的逻辑处理,并且恶意拦截 input file 标签,美美的弹出自己的文件选择框。
兼容都没有做好,有碧莲弹窗。。。。佩服!!!
不知怎么滴灵光一现,就想如果我们去掉拍照这个按钮,问题不就解决了吗?分析布局,目测是 X5 在 WebView 后面动态 add 了一个 android.widget.LinearLayout
, 别问我怎么知道的…
于是乎诞生了如下代码:
1 | 项目中 WebView.java |
主要思想就是在 WebView 的 onViewAdded 方法中做手脚,此方法是作甚的呢?
1 | /** |
非常通俗易懂,当 X5 偷偷的去动态 addView 的时候,所在的父组件此方法必定会被调用,只要过滤一下,记录下来拍照的所属的 View,然后从父组件上调用 removeView 移除掉就好了。
上述问题,曲线救国得以解决。
在问题排查以及解决过程中,腾讯的 X5 文档,以及论坛,QQ 群形同虚设,几乎没有什么有价值的线索,狂吐槽。
X5 估计自己都没有做兼容测试,就动态下发错误的逻辑代码,属实是狂傲。
X5 检测应用崩溃后,降级逻辑总算是有点良心,降低对用户的影响,但是我们的 crash 率飙升了…唉
本文系 Creating JVM language 翻译的第 20 篇。
原文中的代码和原文有不一致的地方均在新的代码仓库中更正过,建议参考新的代码仓库。
在之前的 19 篇博客中,我把实现编程语言的每一步都详细的记录下来了。如果不拿这门语言,练练手,做个东西玩,那就没什么意思了,对不?
我准备实现一个扑克牌的洗牌模拟器。思路是提供一定数量的玩家,指定每个玩家的扑克牌的数量。作为输出,每个玩家都能获得一定数量的随机扑克牌。
1 | Card { |
没有黑科技,就是一个对象,并且是不可变的。
1 | CardDrawer { |
首先我们需要编译 Card 类。然后编译 CardDrawer 类。
1 | java -classpath compiler/target/compiler-1.0-SNAPSHOT-jar-with-dependencies.jar: com.kubadziworski.compiler.Compiler EnkelExamples/RealApp/Card.enk |
完美!
为了实现 Enkel 和分享整个过程我花费了大量的精力。写代码是一回事,能够表达和分享是另一回事(能够让读者通俗易懂本身就是一个很大的挑战)。
在这个过程中我学习到了很多,也希望屏幕前面的你也能有所收获。
很不幸的是,这是系列中的最后一篇了,这个项目将继续下去,所以,保持关注吧!
]]>本文系 Creating JVM language 翻译的第 19 篇。
原文中的代码和原文有不一致的地方均在新的代码仓库中更正过,建议参考新的代码仓库。
对于 Java 初学者来说,对象比较或许是最让人头疼的事情了。
我们来看如下示例:
1 | Integer a = 15; |
这里有个隐式的的类型装箱,Integer.valueOf(15) 会返回缓存的缓存的 Integer 对象,因为引用一样,所以 areEqual 是 true。
上面代码执行完后,Java 菜逼理所当然的想,我可以用 == 来比较对象。
1 | nteger a = 155; |
areEqual 是 false, 这是因为 155 超过了缓存的阈值。
Strings 也有陷阱。比如通过 new 创建的对象获得一个新的引用,如果你通过双引号字符串给变量赋值,拿到的是一个缓存对象。
问题在于,在超过 99% 的情境下,我们比较的是对象是否相等,而不是引用是否相等。我希望 == 意味着相等,而 <, >, <=, >= 调用 compareTo。
我们一起来实现这个功能吧。
在第十部分,条件表达式中,我们引入了比较原始类型的操作。这里我们引入 compareTo,只需要在生成字节码的部分修改就可以了。
基本思路是,判断值,如果是原始类型,调用 compareTo, 如果是引用,调用 equals。
1 | public class ConditionalExpressionGenerator { |
这里需要注意的是:
1 | public boolean equals(Object obj) { |
因此参数需要是 java.lang.Object 类型,没有默认值(Optional.empty)
如下 Enkel 代码:1
2
3
4
5
6
7
8
9EqualitySyntax {
start {
var a = new java.lang.Integer(455)
var b = new java.lang.Integer(455)
print a == b
print a > b
}
}
生成字节码反编译成 Java 如下:
1 | public class EqualitySyntax { |
如你所见,== 被映射成 equals, > 被映射成 compareTo 操作。
]]>本文系 Creating JVM language 翻译的第 18 篇。
原文中的代码和原文有不一致的地方均在新的代码仓库中更正过,建议参考新的代码仓库。
语法规则和 Java 非常类似,但是更加简单,没有复杂的修饰符(比如 static, volatile, transient)。
1 | Fields { |
在本篇之前我们只能在类中定义方法,现在我们开启定义字段的大门吧:
1 | classBody : field* function* ; |
赋值语句:
1 | assignment : name EQUALS expression; |
字段用来赋值,但是这么久以来我们一直没有实现赋值语句来给声明的变量赋值,我这么做是因为有以下考量。
我希望变量是不可变的,赋值意味着改变状态,这会导致许多问题,比如同步,副作用,还有内存泄漏。
比如有如下的 Java 代码:
1 | Stuff trustMeIWontModifyYourArg(SomeObject arg) { |
通过方法签名,我们可能理所当然的想,方法会修改参数吗,他没有 final 修饰,但是大多数 Java 程序员会忽略。仅仅通过名字判断出来方法不会修改变量,那我们就用他吧。
过了两个小时后,出现了 NullPointerException,方法还是修改了参数。
如果方法没有副作用,那么可以很方便的实现并发而不用担心同步的问题,这种方法没有状态,也没有副作用,实现避免副作用方法最简单的办法就是尽可能的使用常量。
使用 ASM 的 visitField 来声明字段。它添加字段到 fields[],fields_count 会自动增加计数器。
1 | public class FieldGenerator { |
读取字段,你需要:
1 | public class ReferenceExpressionGenerator { |
1 | public class AssignmentStatementGenerator { |
如果局部变量和字段名字冲突了,那么局部变量有更高的优先级。
PUTFIELD 和 GETFIELD 相似,但是会出栈顶数据,表达式的值会被赋值到变量
如下 Enkel 文件1
2
3
4
5
6
7
8
9Fields {
int field
start {
field = 5
print field
}
}
生成字节码如下:
1 | public class Fields { |
本文系 Creating JVM language 翻译的第 17 篇。
原文中的代码和原文有不一致的地方均在新的代码仓库中更正过,建议参考新的代码仓库。
所有基于 JVM 的编程语言最终被编译到字节码,然后被虚拟机加载解释执行,这意味着虚拟机并不知道是什么语言生成了字节码。只要类在 classpath 上。
这为 Enkel 调用 Java 库以及其他框架提供了具体可能性。
为了能够引用到其他类,有如下两种选择:
在 Enkel 中,我决定使用第二种方式,主要是出于安全考量。我们可以使用反射 API 来实现:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26public class ClassPathScope {
public Optional<FunctionSignature> getMethodSignature(Type owner, String methodName, List<Type> arguments) {
try {
Class<?> methodOwnerClass = owner.getTypeClass();
Class<?>[] params = arguments.stream()
.map(Type::getTypeClass).toArray(Class<?>[]::new);
Method method = methodOwnerClass.getMethod(methodName,params);
return Optional.of(ReflectionObjectToSignatureMapper.fromMethod(method));
} catch (Exception e) {
return Optional.empty();
}
}
public Optional<FunctionSignature> getConstructorSignature(String className, List<Type> arguments) {
try {
Class<?> methodOwnerClass = Class.forName(className);
Class<?>[] params = arguments.stream()
.map(Type::getTypeClass).toArray(Class<?>[]::new);
Constructor<?> constructor = methodOwnerClass.getConstructor(params);
return Optional.of(ReflectionObjectToSignatureMapper.fromConstructor(constructor));
} catch (Exception e) {
return Optional.empty();
}
}
}
如果方法或者构造器不存在,会抛异常并且终止编译:
1 | //Scope.java |
这种方式看起来更加安全,但是同时也会慢一点。所有的依赖需要在编译的时候通过反射的方式来解决依赖。
下面我们从 Client 类中调用 Library 的方法:
1 | Client { |
1 | Library { |
这里我们需要先编译 Library(我们目前不支持多文件同时编译),否则 Client 没办法编译通过。
1 | kuba@kuba-laptop:~/repos/Enkel-JVM-language$ java -classpath compiler/target/compiler-1.0-SNAPSHOT-jar-with-dependencies.jar:. com.kubadziworski.compiler.Compiler EnkelExamples/ClassPathCalls/Library.enk |
1 |
|
1 | $ java Client |
本文系 Creating JVM language 翻译的第 16 篇。
原文中的代码和原文有不一致的地方均在新的代码仓库中更正过,建议参考新的代码仓库。
面向对象语言中最大的优点是啥?我个人认为是多态, 如何实现多态,使用继承呗,可以在静态语义下使用继承么?肯定不行呀。
在我认为,静态语义违反了面向对象的思想,不应该出现在面向对象中,为了避免使用多态,你可以用单例呀。
因此,为何有 static 情况下,Java 还声称自己是面向对象的呢?我认为,Java 是为了迎合 C++ 更加方便的接纳 Java 才引入的历史因素。
直到上一篇博客,Enkel 中一直存在 static。包括 main 方法和其他的静态方法,这么是为了方便实现语言的其他特性,比如变量,条件表达式,循环,方法调用,然后才转成 OO。
那么我们来实现没有静态的 OO 吧。
static main 方法需要 Java 程序员手动编写。Enkel 是这样处理的:
1 | private Function getGeneratedMainMethod() { |
start 方法是非静态的,但其实是 main 方法的一种变体。
在第七部分,我用了 INVOKESTATIC 来做方法调用,是时候换成 INVOKEVIRTUAL 了。
INVOKEVIRTUAL 有一点和 INVOKESTATIC 有很大的差异,INVOKEVIRTUAL 需要一个所有者,INVOKESTATIC 从栈中出栈参数,INVOKEVIRTUAL 首先是把所有者出栈,然后才是出栈参数。
如果没有显示的提供所有者信息,默认用 this。
1 |
|
1 | //Generating bytecode using mapped FunctionCall object |
如下 Enkel 代码:
1 | HelloStart { |
生成字节码:
1 | public class HelloStart { |
与之对应 Java 类如下:
1 | public class HelloStart { |