MoreRSS

site iconBaoshuo | 宝硕修改

由一位学生运营,主要分享学习、信息学奥林匹克竞赛、计算机网络知识等内容。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Baoshuo | 宝硕的 RSS 预览

从 CSS 字符串到 AST(一)—— 词法分析器(Lexer)的实现

2025-08-05 23:18:52

从 CSS 字符串到 AST(一)—— 词法分析器(Lexer)的实现

最近在实习的时候,遇到了一些需求,需要自己去实现 CSS 的解析、(伪)渲染流程。以之为契机,我学习了一下编译相关的知识,其中的第一环就是 Lexer。

本文中的代码均使用 Go 实现,成果已经作为 Go 库 go.baoshuo.dev/csslexer 发布。

建议在阅读本文前对 CSS 标准内容有一定理解。

词法分析

词法分析(lexical analysis)是计算机科学中将字符序列转换为记号(token,也有译为标记或词元)序列的过程。进行词法分析的程序或者函数叫作词法分析器(lexical analyzer,简称 lexer),也叫扫描器(scanner)。词法分析器一般以函数的形式存在,供语法分析器调用。

——维基百科

词法分析是编译中的第一个步骤。它读入组成源码的字符流,并将他们组织成一个个的词素(lexeme)。有了词素以后,识别并标注它的类型,就可以生成一个 <token-name, attribute-value> 形式的词法单元(token)。这个单元会被传送给下一个步骤 —— 语法分析 —— 进行后续的处理。

在进行词法分析之前,首先要设定好到底有多少种 token 类型,然后再确定每个 token 类型的判断条件和解析方式。

Token 的分类

由 CSS Syntax Module Level 3 中的 4. Tokenization 一节可以得到 CSS 的 token 有以下几种类型:

<ident-token>
<function-token>
<at-keyword-token>
<hash-token>
<string-token>
<bad-string-token>
<url-token>
<bad-url-token>
<delim-token>
<number-token>
<percentage-token>
<dimension-token>
<whitespace-token>
<CDO-token>
<CDC-token>
<colon-token>
<semicolon-token>
<comma-token>
<[-token>
<]-token>
<(-token>
<)-token>
<{-token>
<}-token>

为了解析方便,我们又在标准的 token 类型外拓展了几个 token 类型,得到了下面的 token 表:

<ident-token>         IdentToken
<function-token>      FunctionToken            foo()
<at-keyword-token>    AtKeywordToken           @foo
<hash-token>          HashToken                #foo
<string-token>        StringToken
<bad-string-token>    BadStringToken
<url-token>           UrlToken                 url()
<bad-url-token>       BadUrlToken
<delim-token>         DelimiterToken
<number-token>        NumberToken              3
<percentage-token>    PercentageToken          3%
<dimension-token>     DimensionToken           3em
<whitespace-token>    WhitespaceToken
<CDO-token>           CDOToken                 <!--
<CDC-token>           CDCToken                 -->
<colon-token>         ColonToken               :
<semicolon-token>     SemicolonToken           ;
<comma-token>         CommaToken               ,
<(-token>             LeftParenthesisToken     (
<)-token>             RightParenthesisToken    )
<[-token>             LeftBracketToken         [
<]-token>             RightBracketToken        ]
<{-token>             LeftBraceToken           {
<}-token>             RightBraceToken          }
<EOF-token>           EOFToken

CommentToken          /* ... */
IncludeMatchToken     ~=
DashMatchToken        |=
PrefixMatchToken      ^=
SuffixMatchToken      $=
SubstringMatchToken   *=
ColumnToken           ||
UnicodeRangeToken

于是乎,我们就有了词法分析的期望目标产物 —— 由这 33 种类型的 token 组成的 token 流。

输入流

工欲善其事,必先利其器。

在实现真正的词法分析流程以前,我们需要编写一套输入流来辅助我们完成读入的操作。

首先,我们给出输入流的定义:

// Input represents a stream of runes read from a source.
type Input struct {
    runes []rune // The runes in the input stream.
    pos   int    // The current position in the input stream.
    start int    // The start position of the current token being read.
    err   error  // Any error encountered while reading the input.
}

这个结构封装了对一个 rune 切片的访问,并维护了当前扫描的位置(pos)和当前正在扫描的 token 的起始位置(start)。

需要注意的是,我们使用 rune 而不是 byte 来存储内容,这样做的原因是为了便于处理代码中包含的 Emoji 等 Unicode 字符。

为了使用方便,这个输入流可以从 string[]rune[]byteio.Reader 初始化。实现细节可以查看仓库中的 input.go,各个函数签名如下:

  • NewInput(input string) *Input
  • NewInputRunes(runes []rune) *Input
  • NewInputBytes(input []byte) *Input
  • NewInputReader(r io.Reader) *Input

接下来,我们需要设计一系列合理的方法,使得这个输入流的使用能够在满足我们的实际需求的同时,还保持简洁的风格。

在 4.2 节的一系列定义中,通过观察不难发现,在解析过程中会不断地出现 consume 和 reconsume 的操作,也就是说,在输入流的末尾会不断地进行 pop_back 和 push_back 的操作。那么我们可以将这些操作转化为「预读」和「后移指针」的操作,以此来减少频繁在流末尾进行的弹出和插入操作。

于是,我们就有了以下两个方法:

  • func (z *Input) Peek(n int) rune

    预读输入流中 pos+n 位置的字符。

  • func (z *Input) Move(n int)

    将当前输入流的指针后移 n 位。

经过阅读规范以后,不难发现一个 token 可以由几个不同类别的字符序列组成,比如 16px 就是一个 16 (number sequence) 和一个 px (ident sequence) 共同组成的 dimension-token。所以我们在解析一个 token 的时候可能会调用多个解析函数,那么就需要在 token 级别做一个固定的输出模式。

于是,我们定义 func (z *Input) Shift() []rune 来弹出当前 token,并更新 Input 实例中的 start 值,以开始下一 token 的解析。

不过后续在解析 url-token 的时候遇到了需要读取当前已经 consume 的内容的情况,于是将 Shift 方法拆分成了 CurrentShift 两个不同的方法,以便使用。

除此以外,在解析的时候还有需要在满足某一特定条件下一直 consume 的能力需求,因此又设计了较为通用的 func (z *Input) MoveWhilePredicate(pred func(rune) bool) 方法,来实现这一能力。

加上错误处理逻辑以后,整个 Input 的方法如下:

func (z *Input) PeekErr(pos int) error
func (z *Input) Err() error
func (z *Input) Peek(n int) rune
func (z *Input) Move(n int)
func (z *Input) Current() []rune
func (z *Input) Shift() []rune
func (z *Input) MoveWhilePredicate(pred func(rune) bool)

接下来,我们就可以正式开始 lexer 的编写了。

词法分析器

其实 Lexer 的方法框架设计就相对简单了,下面直接给出定义:

type Lexer struct {
    r *Input // The input stream of runes to be lexed.
}

func (l *Lexer) Err() error
func (l *Lexer) Next() (TokenType, []rune)

Next 方法中有一个巨大的 switch-case 语句,这里面包含了 4.3.1. Consume a token 中所描述的所有在 token 开始时的情形。我们将会根据一个 token 开始的几个字符(小于等于 3 个)来确定这个 token 的后续部分应该如何解析。

Token 开始处的分类讨论

开始解析 token 的时候一定是在文件流的开头或者上一个 token 刚刚解析完毕的时候,那么此时我们只需要根据对应规则判断 token 类型即可。

首先预读 1 个字符,记为 next,然后对这个字符进行分类讨论。

  • EOF:直接返回 EOF-token。

  • \t, \n, \r, \f, :根据标准需要将此字符及后续的所有 whitespace 组合成一个 whitespace-token。

  • /:如果是 /* 则一直读取到 */ 或者 EOF 作为 comment-token。

  • ' (单引号), "(双引号):遇到这两种引号,会调用字符串解析函数 consumeStringToken()。该函数会持续读取字符,直到遇到与之匹配的结束引号。在此过程中,它会处理转义字符(如 \")。如果在中途遇到换行符或文件末尾,则会生成一个 bad-string-token,否则生成一个 string-token。

  • 0 ~ 9 的数字字符:如果以数字开头,确定无疑是数字类型,调用数字解析函数 consumeNumericToken()

  • (, ), [, ], {, }:生成对应的括号字符。function-token 或者 url-token 的情况会在处理 ident-like 的时候另行考虑。

  • +, .:这两个字符,再加上 -,都比较特殊。不过 - 需要包含一些额外的判断,因此归属于另外一条规则处理。

    • 解析器会向后预读,通过 nextCharsAreNumber() 判断后续字符是否能构成一个合法的数字(例如 +1.5, .5)。
    • 如果可以,则调用 consumeNumericToken() 将其完整解析为一个 numeric-token。
    • 如果不构成数字,则 +. 会被当作 delimiter-token。
  • -:除了像 + 一样判断是否有可能进入数字的处理逻辑以外,还需要考虑作为 --> (CDC-token) 和 ident-like 的情况。如果都不是才会被当做 delimiter-token。

    if l.nextCharsAreNumber() {
        return l.consumeNumericToken()
    }
    if l.r.Peek(1) == '-' && l.r.Peek(2) == '>' {
        l.r.Move(3) // consume "-->"
        return CDCToken, l.r.Shift()
    }
    if l.nextCharsAreIdentifier() {
        return l.consumeIdentLikeToken()
    }
    l.r.Move(1)
    return DelimiterToken, l.r.Shift()
  • <:如果能构成 <!--,解析为一个 CDO-token,否则解析为 delimiter-token。

  • *, ^, $, |, ~: 这些是属性选择器中的匹配符。

    • 如果它们后面紧跟 =,则会组合成一个专有 token:
      • *= → substring-match-token
      • ^= → prefix-match-token
      • $= → suffix-match-token
      • ~= → include-match-token
      • |= → dash-match-token
    • 特别地,对于 |,如果能够组成 ||,则会成为 column-token。
    • 如果没有,则单独作为 delimiter-token。
  • @:如果后续的字符能够组成一个 identifier,那么解析为 at-keyword-token,否则解析为 delimiter-token。

  • , (逗号):直接生成 comma-token。

  • : (冒号):直接生成 colon-token。

  • ; (分号):直接生成 semicolon-token。

  • uU:这是一个特殊前缀。如果其后是 + 紧跟着十六进制数字或 ? (例如 U+26 或 u+A?),则调用 consumeUnicodeRangeToken() 解析为一个 urange-token。否则,按标识符处理。

    • 这里有一个坑点,需要在编写 parser 的时候注意,比如 u+a 既是一个合法的 unicode-range,也是一个合法的 selector,需要根据上下文来判定。
  • 1 <= c <= 31, !, %, &, =, >, ?, `, 127:解析为 delimiter-token。

  • 其余字符:尝试解析为 ident-like。

整个流程在 lexer.go 的 24-198 行,由于篇幅原因此处就不贴完整代码了。

Token 解析

为了方便,我们为几种逻辑复杂 / 需要重用的 token 解析逻辑进行了封装,产生了如下函数:

  • consumeNumericToken()

    • 先 consume 一个数字;
    • 如果后续跟一个合法的 name,则 consume 这个 name 作为它的单位,组合为 dimension-token;
    • 如果后续跟一个 %,consume 掉这个 %,产生一个 percentage-token;
    • 否则产生一个 number-token。
    // https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-numeric-token
    func (l *Lexer) consumeNumericToken() (TokenType, []rune) {
        l.consumeNumber()
    
        if l.nextCharsAreIdentifier() {
            l.consumeName()
            return DimensionToken, l.r.Shift()
        } else if l.r.Peek(0) == '%' {
            l.r.Move(1) // consume '%'
            return PercentageToken, l.r.Shift()
        }
    
        return NumberToken, l.r.Shift()
    }
  • consumeUnicodeRangeToken()

    • 有以下几种情况:
      • U+0000FF+ 后面可以跟 1 ~ 6 个 16 进制数字;
      • U+0000??+ 后面先跟 16 进制数字再跟 ?(通配符),总数不超过 6 个;
      • U+0001-0002- 两侧可以有 1 ~ 6 个 16 进制数字。
    • 这些情况需要各自分类讨论,最后产生一个 urange-token。
    // https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#urange
    func (l *Lexer) consumeUnicodeRangeToken() (TokenType, []rune) {
        // range start
        start_length_remaining := 6
        for next := l.r.Peek(0); start_length_remaining > 0 && next != EOF && isASCIIHexDigit(next); next = l.r.Peek(0) {
            l.r.Move(1) // consume the hex digit
            start_length_remaining--
        }
    
        if start_length_remaining > 0 && l.r.Peek(0) == '?' { // wildcard range
            for start_length_remaining > 0 && l.r.Peek(0) == '?' {
                l.r.Move(1) // consume the '?'
                start_length_remaining--
            }
        } else if l.r.Peek(0) == '-' && isASCIIHexDigit(l.r.Peek(1)) { // range end
            l.r.Move(1) // consume the '-'
    
            end_length_remaining := 6
            for next := l.r.Peek(0); end_length_remaining > 0 && next != EOF && isASCIIHexDigit(next); next = l.r.Peek(0) {
                l.r.Move(1) // consume the hex digit
                end_length_remaining--
            }
        }
    
        return UnicodeRangeToken, l.r.Shift()
    }
  • consumeIdentLikeToken()

    • 先 consume 一个合法的 name;
    • 然后判断是否为一个函数的开始,如果是,再判断是否是 url-token,转入特定的解析流程。
      • 需要额外注意的是,如果 url 函数的参数是使用单 / 双引号包裹的字符串,那么按照普通函数参数解析即可。
    // https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-ident-like-token
    func (l *Lexer) consumeIdentLikeToken() (TokenType, []rune) {
        l.consumeName()
    
        if l.r.Peek(0) == '(' {
            l.r.Move(1) // consume the opening parenthesis
            if equalIgnoringASCIICase(l.r.Current(), urlRunes) {
                // The spec is slightly different so as to avoid dropping whitespace
                // tokens, but they wouldn't be used and this is easier.
                l.consumeWhitespace()
    
                next := l.r.Peek(0)
                if next != '"' && next != '\'' {
                    return l.consumeURLToken()
                }
            }
    
            return FunctionToken, l.r.Shift()
        }
    
        return IdentToken, l.r.Shift()
    }

    注意这里的实现其实会在含转义的 URL-token 上出现问题,后续通过修改 consumeName 函数的实现,通过返回值判断解决了此问题。

  • consumeStringToken()

    • 简而言之,就是从开始的引号的位置一直匹配到相对应的结束引号位置或者文件末尾;
    • 特别地,如果遇到没有转义的换行,那么此时就需要作为 bad-string-token 返回了。
    // https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-string-token
    func (l *Lexer) consumeStringToken() (TokenType, []rune) {
        until := l.r.Peek(0) // the opening quote, already checked valid by the caller
        l.r.Move(1)
    
        for {
            next := l.r.Peek(0)
    
            if next == until {
                l.r.Move(1)
                return StringToken, l.r.Shift()
            }
    
            if next == EOF {
                return StringToken, l.r.Shift()
            }
    
            if isCSSNewline(next) {
                return BadStringToken, l.r.Shift()
            }
    
            if next == '\\' {
                next_next := l.r.Peek(1)
    
                if next_next == EOF {
                    l.r.Move(1) // consume the backslash
                    continue
                }
    
                if isCSSNewline(next_next) {
                    l.r.Move(1)
                    l.consumeSingleWhitespace()
                } else if twoCharsAreValidEscape(next, next_next) {
                    l.r.Move(1) // consume the backslash
                    l.consumeEscape()
                } else {
                    l.r.Move(1)
                }
            } else {
                l.r.Move(1) // consume the current rune
            }
        }
    }
  • consumeURLToken()

    • 需要按照规范特别注意 bad-url-token 的情况。
    • 但此处的实现和规范不同,在 consumeIdentLikeToken() 中我们把 URL 的前导空格全部 consume 掉了,但如果遇到使用引号包裹的 URL 时,这段空格理应单独作为一个 whitespace-token,不过无伤大雅,这样解析也可以,不影响后续的 parse 流程。
    // https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-url-token
    func (l *Lexer) consumeURLToken() (TokenType, []rune) {
        for {
            next := l.r.Peek(0)
    
            if next == ')' {
                l.r.Move(1)
                return UrlToken, l.r.Shift()
            }
    
            if next == EOF {
                return UrlToken, l.r.Shift()
            }
    
            if isHTMLWhitespace(next) {
                l.consumeWhitespace()
    
                next_next := l.r.Peek(0)
                if next_next == ')' {
                    l.r.Move(1) // consume the closing parenthesis
                    return UrlToken, l.r.Shift()
                }
                if next_next == EOF {
                    return UrlToken, l.r.Shift()
                }
    
                // If the next character is not a closing parenthesis, there's an error and we should mark it as a bad URL token.
                break
            }
    
            if next == '"' || next == '\'' || isNonPrintableCodePoint(next) {
                l.r.Move(1) // consume the invalid character
                break
            }
    
            if next == '\\' {
                if twoCharsAreValidEscape(next, l.r.Peek(1)) {
                    l.r.Move(1) // consume the backslash
                    l.consumeEscape()
                    continue
                } else {
                    break
                }
            }
    
            l.r.Move(1) // consume the current rune
        }
    
        l.consumeBadUrlRemnants()
        return BadUrlToken, l.r.Shift()
    }

特定类型字符片段解析

一共有以下几个片段解析的函数:

  • consumeUntilCommentEnd():一直读取到注释结束。

    // https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-comment
    func (l *Lexer) consumeUntilCommentEnd() {
        for {
            next := l.r.Peek(0)
    
            if next == EOF {
                break
            }
    
            if next == '*' && l.r.Peek(1) == '/' {
                l.r.Move(2) // consume '*/'
                return
            }
    
            l.r.Move(1) // consume the current rune
        }
    }
  • consumeEscape():解析一个转义字符。

    // https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-escaped-code-point
    func (l *Lexer) consumeEscape() rune {
        var res rune = 0
    
        next := l.r.Peek(0)
    
        if isASCIIHexDigit(next) {
            l.r.Move(1)
            res = hexDigitToValue(next)
    
            for i := 1; i < 6; i++ {
                c := l.r.Peek(0)
                if isASCIIHexDigit(c) {
                    l.r.Move(1)
                    res = res*16 + hexDigitToValue(c)
                } else {
                    break
                }
            }
    
            if !isValidCodePoint(res) {
                res = '\uFFFD' // U+FFFD REPLACEMENT CHARACTER
            }
    
            // If the next input code point is whitespace, consume it as well.
            l.consumeSingleWhitespace()
        } else if next != EOF {
            l.r.Move(1) // consume the escape character
            res = next
        } else {
            res = '\uFFFD' // U+FFFD REPLACEMENT CHARACTER for EOF
        }
    
        return res
    }
  • consumeName():读取一个 name。

    // https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-name
    func (l *Lexer) consumeName() {
        for {
            next := l.r.Peek(0)
    
            if isNameCodePoint(next) {
                l.r.Move(1)
            } else if twoCharsAreValidEscape(next, l.r.Peek(1)) {
                l.r.Move(1) // consume the backslash
                l.consumeEscape()
            } else {
                break
            }
        }
    }
  • consumeNumber():读取一个数字。需要特别注意对科学计数法的处理,以及与调用侧配合正确解析 .7 +.7 等 case。

    // https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-number
    func (l *Lexer) consumeNumber() {
        next := l.r.Peek(0)
    
        // If the next rune is '+' or '-', consume it as part of the number.
        if next == '+' || next == '-' {
            l.r.Move(1)
        }
    
        // consume the integer part of the number
        l.r.MoveWhilePredicate(isASCIIDigit)
    
        // float
        next = l.r.Peek(0)
        if next == '.' && isASCIIDigit(l.r.Peek(1)) {
            l.r.Move(1) // consume the '.'
            l.r.MoveWhilePredicate(isASCIIDigit)
        }
    
        // scientific notation
        next = l.r.Peek(0)
        if next == 'e' || next == 'E' {
            next_next := l.r.Peek(1)
    
            if isASCIIDigit(next_next) {
                l.r.Move(1) // consume 'e' or 'E'
                l.r.MoveWhilePredicate(isASCIIDigit)
            } else if (next_next == '+' || next_next == '-') && isASCIIDigit(l.r.Peek(2)) {
                l.r.Move(2) // consume 'e' or 'E' and the sign
                l.r.MoveWhilePredicate(isASCIIDigit)
            }
        }
    }
  • consumeSingleWhitespace():读取一个空格。

    func (l *Lexer) consumeSingleWhitespace() {
        next := l.r.Peek(0)
        if next == '\r' && l.r.Peek(1) == '\n' {
            l.r.Move(2) // consume CRLF
        } else if isHTMLWhitespace(next) {
            l.r.Move(1) // consume the whitespace character
        }
    }
  • consumeWhitespace():读取多个空格。

    func (l *Lexer) consumeWhitespace() {
        for {
            next := l.r.Peek(0)
    
            if isHTMLWhitespace(next) {
                l.consumeSingleWhitespace()
            } else if next == EOF {
                return
            } else {
                break
            }
        }
    }
  • consumeBadUrlRemnants():读取 bad-url-token 的剩余部分。

    // https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-the-remnants-of-a-bad-url
    func (l *Lexer) consumeBadUrlRemnants() {
        for {
            next := l.r.Peek(0)
    
            if next == ')' {
                l.r.Move(1)
                return
            }
            if next == EOF {
                return
            }
    
            if twoCharsAreValidEscape(next, l.r.Peek(1)) {
                l.r.Move(1) // consume the backslash
                l.consumeEscape()
                continue
            }
    
            l.r.Move(1)
        }
    }

Identifier 和 Number 的鉴别逻辑

对于 identifier,我们根据以下标准判断接下来的字符是否可能开始一个 identifier 的序列:

  • 第一位是 NameStartCodePoint(以英文字母、下划线或非 ASCII 字母开始);或
  • 第一位和第二位组合起来可以开始一段转义序列;或
  • - 开始的 identifier(再走一遍上面两点的识别流程,同时注意 -- 的情况)。
// https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#would-start-an-identifier
func (l *Lexer) nextCharsAreIdentifier() bool {
    first := l.r.Peek(0)

    if isNameStartCodePoint(first) {
        return true
    }

    second := l.r.Peek(1)

    if twoCharsAreValidEscape(first, second) {
        return true
    }

    if first == '-' {
        return isNameStartCodePoint(second) || second == '-' ||
            twoCharsAreValidEscape(second, l.r.Peek(2))
    }

    return false
}

对于 number,当符合以下条件的时候可以开始一个 number 的序列:

  • 第一位是数字;
  • 第一位是正负号,第二位是数字;
  • 第一位是正负号,第二位是小数点,第三位是数字;
  • 第一位是小数点,第二位是数字。
// https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#starts-with-a-number
func (l *Lexer) nextCharsAreNumber() bool {
    first := l.r.Peek(0)

    if isASCIIDigit(first) {
        return true
    }

    second := l.r.Peek(1)

    if first == '+' || first == '-' {
        if isASCIIDigit(second) {
            return true
        }

        if second == '.' {
            third := l.r.Peek(2)

            if isASCIIDigit(third) {
                return true
            }
        }
    }

    if first == '.' {
        return isASCIIDigit(second)
    }

    return false
}

小结

让我们来总结一下 lexer 工作流程:在 lexer 读取到某个 token 的起始点的时候,lexer 预读起始的几个字符,然后辨别 token 的类型。对于大致分类好的 token,根据其更具体的特征预读并消耗掉对应的字符,直到这个 token 结束。

大致的类型辨别是通过 Next() 函数中的那个巨大的 switch-case 语句来完成的。而对于精细的 token 类型的判断,则是 case 中的语句和 consume_token.go 定义的一系列函数来共同完成的。至于 token 内部的字符段的解析,则是 consume.go 中的一系列函数完成的。由此组合,整个 token 的解析过程得以良好运转。

除了文中提到的相关方法以外,在 util.go 中还有一系列的工具函数:

  • func isASCII(c rune) bool
  • func isASCIIAlpha(c rune) bool
  • func isASCIIDigit(c rune) bool
  • func isASCIIHexDigit(c rune) bool
  • func isCSSNewline(c rune) bool
  • func isNameStartCodePoint(r rune) bool
  • func isNameCodePoint(r rune) bool
  • func isNonPrintableCodePoint(r rune) bool
  • func twoCharsAreValidEscape(first, second rune) bool
  • func isHTMLSpecialWhitespace(c rune) bool
  • func isHTMLWhitespace(c rune) bool

这些函数的作用可以很容易地由它们的名字得知,故此处不再赘述。

测试

为了验证 lexer 的实现正确性,我们引入了 romainmenke/css-tokenizer-tests 的测试用例来对 lexer 进行测试。具体的测试流程可以参考 lexer_test.go 中的实现。

根据测试结果来看,出现的问题主要集中在与转义字符相关的处理,对于大部分情况已经能够正常解析。截止编写本文之时,测试通过率为 96.53% (167/173),个人认为已经处于可用水平。

后记

文中所述的 lexer 的具体实现已经开源在 renbaoshuo/go-css-lexer,欢迎大家 Star!

搓这个 lexer 花了半个周末的时间,修修补补又消耗了一些时间。也算是在工作之余充实自己的大脑了。后续还可能会针对预读相关的内存访问进行优化(不知道读者有没有发现最多会预读三个字符),以提升处理效率。

文章题图由 Gemini 2.5 Pro Imagen 生成。

向着璀璨的未来进发 —— 我的 2024 年度总结

2025-01-28 22:57:30

向着璀璨的未来进发 —— 我的 2024 年度总结

即使是沉重的过去,也要接受它再继续向前迈进。

2024 年就这样过去了。上半年发生的一系列事情对我仿佛一场梦一样,而下半年我才回到真实的生活中。

前一阵子有读者来询问我的近况,同时进行了一个博客更新的催。其实我有写几篇草稿,不过中途废弃了一些,另一些还没有完成,所以一整年都没有产出。这篇总结中也会截取弃稿中一些完成度比较高的部分分享给各位读者。

高考

备战高考是我今年上半年的主旋律。从 2023 年 5 月 4 日正式回班,到 2024 年 6 月 6 日(高考前一天),刚好经历了整整四百天。

从起初的三百余分,到高考的 615 分,我实现了在旁人看来几乎不可能的飞跃。我没有任何理由不为我自己的巨大进步而感到自豪。我在刚出考场时的估分恰好就是 615 分,这么准确的估分我觉得也可以吹很久了。总的来说,我用自己的实力与汗水,狠狠地打了某些幻想看我过不了本科线的笑话的 “学生” “老师” 和 “家长” 的脸。

在实在学不下去的时候,由于没有任何的娱乐设施,我只能通过写文章来做一些与学习无关的事情。受精神状态影响,这些文章的行文思路可能不是很正常,表达的情感也会比较激烈,还望各位读者海涵。

《从零开始的异世界文化课生活》

在一些比较特殊的日子里,我会用日记的形式来记录近况。《从零开始的异世界文化课生活》是一篇日记选集,笔者从中再次截取了一部分比较有代表性的内容。

节选一

竞赛机房的灯熄灭了。我心中的火,也骤然灭了。笃行楼陷入了黑暗之中。我踩着黑暗,一步步地走向楼下。这段熟悉地不能再熟悉的路,我已经走了许多年。

在昏暗的夜色中,教学楼的灯光确实格外璀璨,无数学子正在其中奋笔疾书,追逐属于他们的梦想。可惜这梦想并不属于我,我的梦想已然破灭。

我背着书包,走向了教学楼,再也没有回头……

▲ 从存真楼旧竞赛机房向远处望去。摄于 2022 年 6 月。

▲ 从实验楼五楼向下望去。摄于 2022 年 2 月。

节选二

很多人都说竞赛生们个个智商超群,学习能力异于常人,可我却不这么认为。站在聚光灯下的人终究是那一小部分,从总体上来看,大部分人还是陪跑的选手。很不幸,我就在陪跑之列。竞赛招生时,教练们口中的「半个月逆袭登顶」的神话也不是人人都能书写的。

我终究还是要独自面对这冰冷残酷的现实。

回到班后,我发现教室内甚至已经没有了我的座位 —— 只有花名册上的名字证明着我属于这个班。甚至考试的时候都不会给我分配考场。我确实成为了一个透明人。一张张陌生的面孔,一个个陌生的名字,一阵阵陌生的声音…… 我虽然回到了我自己的班级,但却没有得到任何归属感。

节选三

虽然学习是一名高中生的本职工作,但是我仍然要指出,我并不喜欢囿于应试教育的牢笼之中。每天机械地刷题、考试,于宿舍、食堂和教室三点一线间往返,并非我所向往的生活。我希望我能够将青春的火焰燃烧在我所热爱的事业上,而不是在一摞摞试卷中消磨殆尽。

一次次的模拟考试,每次我的名字都会出现在成绩单的末尾。在一个几乎人人都能上 600 分的班级里面,我三四百分的成绩显得格外突兀。

相比于大多数同学们的包容,数任班主任和年级主任都有想过把我从省理科实验班里面给踢出去。好在到最后也没有成功。能够留在这个班里,算是不幸中的万幸。他们可否想过:如果我真的离开了这个班级,那我该怎么重新适应一个新的环境,该怎么重新接纳一批新老师的讲课风格,又该怎么与一批新的同学相处?我猜,他们并没有想过。

▲ 高三时的教室。摄于暑假放假后,此时同学们都已把自己的书本搬回了家,准备接下来的线上学习。

节选四

高考前一百天左右,我的座位被固定了下来。

我坐在教室的倒数第二排,靠窗户的位置。有句话叫「后排靠窗,主角故乡」,希望能够在我身上应验。

另外,靠后的位置更有利于我自由发挥 —— 毕竟我的进度和班里面其他人的进度还是不太一样的,所以课上内容不能全听。

坐在这里学累了能够转向窗外看风景也是一件美事啊~

节选五

我站在天台上,冷风直吹着我的脸庞。向下望去,大地突然显得十分亲切,仿佛在等待着我的造访。独处一会,我最终还是选择从楼梯走下去。我不知道我是在逃避什么,还是在寻找什么。我只知道,我不能就这样放弃。

▲ 黄昏时的天台外景。我是从忘记锁门的维修通道溜上去的,那段时间学校在烫房顶。

《四百天的疯狂之后》(节选)

此文写于高考期间。节选中删去了一些琐碎的细节部分。

四百天很长,能让我从一名小白变成一名可以安稳面对高考的成熟学生。四百天也很短,回班的日子仿佛还是昨天。

明天就要高考了,而我还坐在这里写文章。心里是不是该有愧疚感呢?我不知道。不过我觉得再继续临时抱佛脚也只能够让自己乱了阵脚罢了。

尽人事,听天命。还有那不能缺席的:高考加油!


高考第一天。

上午的语文考试只能说是正常发挥,没有什么特别的感觉。因为还有四科考试,所以也没有和别人对答案。

下午的数学考试是题型改革后的第一次高考。前面的选择填空感觉还好,第一道大题是解三角形,也比较常规。但是到了第二道大题,炸裂的就来了 —— 圆锥曲线放到第二题考,打了个措手不及。要知道,我对自己的定位是丢弃掉导数和圆锥曲线这两类大题的。但是放到第二道大题的位置,理论上不应该不拿这分。所以我只能硬着头皮做了下去,结果没做出来。接下来的几道大题也并不顺利,一道做完的都没有(好像其他同学也差不多)。不过十九题做了两问,在同学们里面算做得比较多的了。

晚上由于大家的数学都很稀碎,班里面基本上没有多少动笔的声音,许多人都在发呆。班主任老师见状紧急开会平复大家的心情,以免影响次日的考试。

(对答案后补充:选择和填空加起来应该只扣了两分。第十九题写了的两问全部正确。感觉能 120+ 了)

(出分后补充:真的考了 120 分欸)


高考第二天。

上午的物理答题意外地顺利,除了第一道大题卡了三分钟和本来就没想着做的最后一道大题以外,其他的题目都莫名其妙地顺利。出了考场以后我甚至跟老师自信地说能上 90 分(出分后补充:没上,只考了 72 分)。于是立马兴奋了起来,昨天数学带来的阴霾一扫而空。

下午的英语考试感觉自己彻底进入了状态。答完卷子以后发现还剩四五十分钟,遂捂住之前的答案再做了一遍。做完后发现有三处不一样的。仔细检查后修改了其中的两处。出考场后告知任课老师这个情况的时候她还安慰我,让我不要慌,这个是正常现象。(出分后补充:考了 137 分)

晚上又看了看化学。生物明天考完化学再看。


化学好难。不是说一年比一年简单的吗?怎么今年考完化竞生都落泪了。抱着老师哭的都出来了……

考生物的时候,我的笔速愈发加快,花了不到一个小时就做完了。然后就是等待高考结束了。这个时候的我已经没有丝毫检查的欲望了,只想快点结束这该死的考试回家睡大觉。

出考场,大概估了一下,应该能考 615 分左右吧,就会这些了。

▲ 高考结束后家委会送来的小蛋糕。

就这样吧。

志愿填报

在近一个星期紧张刺激的志愿填报过后,我根据自己的成绩、结合目标院校层次,筛选出了 96 个志愿 —— 卡着志愿填报的上限 —— 作为我的最终填报志愿。

根据我的期望,最终录取志愿会落在 40 ~ 60 序号的志愿上。等到录取的那天,面对迟迟没有动静的河北省教育考试院系统,我转向了各个大学的招生办的录取查询系统进行查询,最终在第 45 号志愿所对应的福州大学的查询系统上查询到自己被录取到了计算机科学与技术专业。

不久后(但是依然是焦急的等待),我就收到了录取通知书:

▲ 福州大学录取通知书外封套。从福州发出到石家庄签收全程只花了十几个小时。

虽然我知道,在这个人均 985 的时代,一张 211 的文凭已经不算什么了。但是我仍为我能通过自己的努力考上一所好学校而感到自豪。

大学

8 月 30 日,我从石家庄启程,经由南京转机,最后抵达了福建福州,准备开始我的大学生活。

24 年的下半年可以说是非常顺畅的:

  • 考上了一所好大学。
  • 遇到了一群好老师。
  • 分到了几位好舍友。

此外,我还加入了福州大学西二在线工作室(校计算机协会),遇到了一群有着相同爱好的好朋友们。

下面是日常随拍分享时间:

▲ 福州大学铜盘校区教学楼。摄于 2024 年 9 月 5 日。

▲ 福州福山郊野公园。

▲ 福州西湖公园。

▲ 落日。

▲ 从鼓山上俯瞰福州城。摄于 2024 年 12 月 20 日。

▲ 从鼓山上夜瞰福州城。摄于 2024 年 12 月 20 日。

▲ 贵安欢乐世界一角。摄于 2024 年 12 月 31 日。

追番

看过

  • 请问您今天要来点兔子吗?系列
    • 请问您今天要来点兔子吗?(第一季)
    • 请问您今天要来点兔子吗??(第二季)
    • 请问您今天要来点兔子吗??~Sing for You~(剧场版)
  • 不时轻声地以俄语遮羞的邻座艾莉同学
  • 亚托莉 -我挚爱的时光-
  • (剧场版)间谍过家家 代号:白
  • 葬送的芙莉莲

想看

  • 请问您今天要来点兔子吗?BLOOM
  • (剧场版)请问您今天要来点兔子吗??~Dear My Sister~
  • 魔女之旅

业余无线电

在大学生活之余,我还完成了我中学时的一个愿望 —— 考取业余无线电台操作证,成为一名 HAM。

若要了解更多信息,请访问网址:https://baoshuo.ren/bd3rnw/

后记

过去的 2024 年总体上来说还是比较顺利的。不过上了大学以后各种事情明显变多了,搞得年终总结都拖了好久😂。

最后,祝大家新的一年里身体健康,万事如意!

愿此去前路皆坦途 —— 我的 2023 年度总结

2024-01-01 13:10:00

愿此去前路皆坦途 —— 我的 2023 年度总结

2023 年就这样在恍恍惚惚间过去了,在这一年中发生了许多事情,就让我挑一些大家可能感兴趣的事情来讲讲吧。

回归文化课

如我在《我的 OI 生涯 —— 一名退役竞赛生的回忆录》中所述,我在竞赛失利后,已经选择了回归文化课道路。在回班以后,不时有好友、读者向我私信或者邮件询问我的近况。由于寄宿制学校放假时间极短,未能一一详尽回答,所以我将在此介绍一下我的近况,以回应各位热心读者的关切。

博君一笑

首先先给大家看点好笑的:

在班里感觉如何?

截止到现在,我在回班以后主要分为了以下几个阶段:

  • 心态恢复期(4 月 ~ 5 月)
  • 一轮复习前期(5 月 ~ 7 月上旬)
  • 暑假(7 月下旬 ~ 8 月)
  • 一轮复习后期(9 月以后)

随着时间的推移,我从最开始几个月的 “听天书” 到现在已经逐渐适应了班内的学习节奏。虽然由于高一高二的长时间停课导致现在的成绩不太理想,但我相信通过一轮复习,我的知识水平会得到很大的提升。虽然今后还有一段很艰苦的道路要走,但我坚信只要努力就能克服路途上的艰难险阻,到达成功的彼岸。

想上什么大学?

暂时还没想好。考哪算哪,不强求。

考虑出国吗?

暂时不考虑。原因有三:

  • 语言:我的英语水平不算很高,出国后可能会存在沟通障碍;
  • 耗财:出国留学需要不少费用,我更希望将家里的钱花到一个合适的地方去,而不是浪费在我身上;
  • 思乡:我希望能有多一些的陪伴家人的时间,在出国以后回家的机会可能会大大减少,这于我来说不太能接受。

考虑复读吗?

高考之日未到,现在谈复读与否其实有点早。我个人以及我家长的意见都倾向于不复读。

复读,意味着又要承受一年高三的巨大压力,这对于一个人的身体和心理都是一个巨大的挑战,而我的身体较为羸弱,恐怕很难再扛得住一年这样的压力。除此之外,复读还使我在一条我不喜欢的且充满不确定性的道路上多耗费了整整一年的光阴,这样做真的值得吗?我不太好回答这个问题。

竞赛对你的高考有什么帮助吗?

在强基计划公布以后,除非取得国家级的奖项,否则竞赛对高考已经没有了什么实质性的帮助,省一等奖最多也就给三四十分的优惠,所以最后还得看文化课的水平到底如何。

我学竞赛并无太多功利因素,更多的是怀揣着一份对计算机的热爱,这也是支撑着我度过这四年有余的竞赛生涯的最关键因素。此外,我也没见过几个一心为了功利还能取得好成绩的竞赛生。毕竟竞赛的学习过程并不轻松,且其对文化课的影响常常是显著的,所以从功利的角度来看,学习竞赛显然是不划算的。

不过,如果再给我一次机会,我还会选择学习竞赛。正如我在《我的 OI 生涯 —— 一名退役竞赛生的回忆录》中所述,竞赛带给我的并不仅仅是那几张薄薄的证书,更多的是思维方式的蜕变,这将在我今后的人生中产生深远影响。

有什么想对学弟学妹们说的吗?

高一的时候一定要打牢文化课基础,不然等到省选前有你紧张到哭的时候。我就是一个很好的反面教材,高一停课停早了导致文化课约等于没学,结果最后几场比赛前就非常害怕退役回去学文化课,于是就整夜整夜的失眠。

追番情况

动漫于我的意义并不只是看个 “动画”,一段精彩的作画、一段感人的故事、一段轻松的日常,都能以其积极向上的乐观主义精神,将我从低谷中拉出来,使我能够更加乐观地面对今后的人生道路。

《别当欧尼酱了!》(2023 年 1 月)

  • 评分:6 分(还行)
  • 短评:剧情还可以,但不算完全合我口味。

《我推的孩子》(2023 年 4 月)

  • 评分:6 分(还行)
  • 短评:有点玄幻?看个乐呵也挺好的。

《孤独摇滚》(2022 年 10 月)

  • 评分:8 分(佳作)
  • 短评:在上映近一年之后才抽出时间来看这部番,看完以后为波奇的改变而欣慰,同时也非常喜欢活泼开朗的虹夏。是一部非常值得去看的好番。

《间谍过家家(第二季)》(2023 年 10 月)

  • 评分:7 分(可以)
  • 短评:一如既往的家庭喜剧,欢乐多彩的家庭日常。

(二刷)《莉可丽丝》(2022 年 7 月)

  • 评分:8 分(佳作)
  • 短评:喜欢千束的活泼开朗以及面对困难时的积极向上,同时也非常羡慕千束和泷奈之间真挚的感情。

▲ 我宿舍内悬挂的《莉可丽丝》海报

(三刷)《干物妹!小埋》系列

  • 评分:9 分(神作)
  • 短评:最能打动我的一部番。轻松愉快的日常、真挚热烈的友情,无不令我心驰神往。同时也从大平的身上看到了自己的影子。

(二刷)《天使降临到我身边》系列

  • 评分:8 分(佳作)
  • 短评:欢乐而充实的孩童日常。喜欢可爱的孩子们。

一些照片

▲ 故地重游(参见:USTC Hackergame 2021 旅行照片

▲ 燕山大学

▲ 二南随拍

GitHub 活动概况

由于学业因素,在过去的一年里我用来写代码的时间大大减少。不出意外的话,在高考结束以前我都会保持这种低频活动状态。

个人主页

对整体布局进行了一些重新设计。此外我还计划将其迁移至 Next.js 13 App Router,但尚未完工。

后记

在新的一年里,我会继续冲刺高考,争取考一所好大学。同时也在此感谢读者们对我的关心,不过由于我长期住校,故评论、邮件等可能不会及时回复,敬请谅解。

最后,祝大家新的一年里身体健康,万事如意!

如何创建一个打印友好型的网页

2023-05-28 11:31:46

如何创建一个打印友好型的网页

在某些情况下,我们会遇到需要将网页打印出来的需求。但是,直接打印网页的效果往往不尽如人意,因为网页的排版和打印的排版是不同的。本文将介绍如何创建一个在打印时具有出色的质量和可读性的网页。

前置知识:@media print 媒体查询

经常编写 CSS 的读者应该对 @media 媒体查询是比较熟悉的了。这个语句在创建响应式网页时是非常有用的,经常被大家用来调整不同屏幕宽度的设备间的样式。而 @media print 媒体查询则是专门用来调整打印时的样式的。

@media print 媒体查询的语法如下:

@media print {
  /* 在这里定义打印时应用的样式 */
  body {
    font-size: 12pt;
  }

  .header,
  .footer {
    display: none;
  }

  /* 更多样式规则... */
}

这些样式只会在打印时应用,而不会在屏幕上显示。了解了 @media print 媒体查询的基本语法后,我们就可以开始创建打印友好型的网页了。

优化内容和布局

隐藏不必要的页面元素、样式

在打印时,页面上的一些与正文无关的元素需要被隐藏掉。

比如在《二分图学习笔记》页面中(本文在后续部分中将会一直以本页面作为示例),顶部的导航栏以及右侧的侧边栏与正文信息并没有什么关联,因此可以在打印输出中隐去。

确保信息的整齐和清晰可读性

▲ IT 之家某篇文章的打印版截图。

从这张截图中可以看出这个页面似乎并没有对打印机进行适配,并且侧边栏还遮挡到了正文中的文字。不过由于笔者并没有找到更好的遮挡示例,因此只能给出这么一个有点勉强的例子 —— 侧边栏按照上一节中的建议是应该要被隐藏掉的。

对于这种情况,需要在设计、编写页面布局的时候下功夫,以避免遮挡到正文。

除了文字被遮挡的问题,截图下部的超链接在纸质媒介上显然是不能被点击的。

以此处的超链接为例,可以通过特殊处理来在纸上显示出链接的实际指向 URL:

@media print {
  a:not([href^='#'])::after {
    content: ' (' attr(href) ')';
    font-size: 80%;
    color: var(--color-fg-muted);
  }
}

效果如图:

除此之外,如果需要,还要对字体及其大小进行一些调整。

笔者认为在相当一部分情况下,使用衬线字体在打印后的观感要比使用非衬线字体时好很多。(PS:在此对通篇使用微软雅黑出试卷的老师表示强烈谴责)

多媒体内容的处理

有时网页上会包含一些音频、视频等多媒体内容,这些内容在纸质媒介上与超链接类似,无法与读者交互。

此时可以考虑提供一些替代文本来对其内容进行描述,并提供指向相关资源的链接、二维码等辅助工具来帮助读者获取多媒体资源中的信息。

编写适合打印的样式

单位制

在网络世界中,我们的常用单位诸如像素(px)、百分比(%)、相对大小(em、rem) 等。然而在现实世界中,我们常用的单位则为物理单位,如厘米(cm)、点(pt)等。这导致了在打印输出时需要额外注意单位制相关的问题。虽然现代浏览器对这些问题的处理已经比较优秀了,但在部分情况下仍然会导致页面排版布局出现错乱。

CSS 优先级

经常写 CSS 的读者应该对 CSS 中的样式优先级不陌生了。笔者建议在编写 CSS 时将打印相关样式置于靠下的位置以免产生冲突,同时也可以适当地使用 !important 来强制覆盖一些样式。

测试和调整

可以使用 DevTools 来模拟打印环境进行调试(按 Ctrl + Shift + P 组合键唤出菜单;对于中文版浏览器,请搜索「打印」关键字)。

演示

感兴趣的读者可以访问 oi.baoshuo.ren/bi-graph 并尝试打印该页面。

▲ 原网页

▲ 打印效果(预览)

我的 OI 生涯 —— 一名退役竞赛生的回忆录

2023-04-02 17:33:44

我的 OI 生涯 —— 一名退役竞赛生的回忆录

在经历了四年半的不算短也不算长的时光后,我的 OI 生涯画上了一个并不算圆满的句号。

是的,我退役了。


写回忆录的本质是自己给自己整理遗容。

—— 郑渊洁《舒克和贝塔历险记》

谨以此文纪念我与 OI 一同逝去的青春。

OI 之路:我的成长历程

我第一次接触信息学竞赛时在初一上学期(2018 年)。当时学校与旁边的高中部合作开设了「信息贯通」课程,使得我在信息老师的帮助下了解到了信息学竞赛这个东西。这便是一切的开端了。

在学 OI 之前,我已经具有了一定的 Python 基础,并且还掌握了一些网页开发相关技能。不过这些东西和 OI 并没有什么关系,如果硬要说有的话,那么这些东西对我的帮助就是使得我的 C++ 语法入门过程并没有那么痛苦,促使了我留下来继续深入学习 OI 知识。

我对计算机有着与众不同的兴趣 —— 别的同龄人用电脑基本上都是打游戏,而我用电脑则是折腾软硬件、写写代码等等。在接触 OI 之后,我找到了有着相同兴趣的一群小伙伴,我们可以在一起交流很多计算机相关的东西 —— 大多是算法相关的内容 —— 我们都为代码可以实现的无限可能性着迷。这让我对 OI 的喜爱更甚 —— 又能学知识,还能结交好友。

▲ 初中开设的「信息贯通」课程正在授课。来源于学校微信公众号。本人跟随高中部学习,因此不在照片中。

不过与此同时,我在班级里并不是很合群,因为我不打游戏。当时流行的游戏叫做《王者荣耀》,同学们周末都会废寝忘食的去玩它,然后在返校后的课余时间交流上周末打游戏的心得,以及规划下次放假的游戏时间。而我因为对游戏没有兴趣,所以很难插上话。这使得我与班级的主体渐行渐远,转而更加亲近我们这个小圈子,在这个圈子里我能获得更多的认同感和归属感。

我初中的 OI 生涯到初三下学期(2021 年)告一段落。初三下学期是一段比较痛苦的日子 —— 我需要补习文化课,来应对即将到来的中考。我和我在学习 OI 时认识的邻班的好伙伴赵泽峰同学一起互帮互助(其实还是我向他取经比较多),共同学习。那段时间几乎每天我们两个都是最后回宿舍睡觉的人。最后的结果很令人振奋,我们都考上了我们理想的高中 —— 石家庄二中实验学校,也就是前文中提到的高中部,这所重点高中有着专业的教练团队和竞赛培养体系,是学习竞赛的好去处。

▲ 二南日落。本人在 2022 年 6 月摄于石家庄二中实验学校存真楼上。

进入高中后,我有更多的时间学习 OI,但相应地,学习文化课的时间减少了。我最初被分入了竞赛班,但我的成绩排在很靠后的位置,这是因为我不仅文化课考不了高分,而且不能兼顾竞赛和文化课的学习。这招来了文化课老师的不满 —— 学竞赛不拿金牌最后还得学文化课,而且文化课成绩太差会拉低班级平均分,这显然是他们所不想看到的。好在我高一下学期被编入了另外一个省理科实验班,这个班的班主任是上一届带竞赛班的班主任(我先前在竞赛班时班主任从没有接触过竞赛生),所以相比之下高一下学期时来自文化课班的压力要减轻许多。

高一下学期的期中考试结束后,我停课了。这给了我充足的时间去研究一些较为困难的知识点,这对我来说是一大收获。

▲ 我在存真楼上旧信息中心 NOI 教室 3 中的机位。由本人在 2022 年 6 月拍摄。

然后我就进入了高二,每天都被模拟赛压得喘不过气来。当时基本上每天的规划都是上午模拟赛,下午改题,晚上隔三岔五的还会有南校自己办的「基础模拟赛」—— 专练第一、二题难度,防止挂分(虽然该挂的还得挂)。

直到快要退役的时候,才能真正体会到往届学长们的痛楚。我送走了好几届学长,这次终于要成为了被送走的那一批。CSP-S 2022 拿了个一等,全省二十多名,这应该就是我能够达到的最好的成绩了吧。NOIP 2022 被取消了,没有考成。春季赛和省选又给我强行续了几个月的命,但于事无补。

▲ 我在 CSP-S 2022 中获得的获奖证书。

我的 OI 之旅到这里就结束了。退役之后特别喜欢学长们常说的一句话:菜是原罪。如果我的实力能够再强一些的话,我肯定不用担心退役这件事情。但即使最终的结局必然是退役,我也无悔竞赛。

收获

思维方式的转变

在学习竞赛的过程中,我收获了许多宝贵的经验和知识。其中最重要的收获之一就是我的思维方式进行了深刻的转变。

竞赛知识点的数量很大,并且通常都比较深入、复杂、抽象。这要求我们必须具备良好的理科思维和创新思维,能够将问题进行深入研究,并将其与实际问题相结合,产生新的想法和解决方案,从而在比赛中熟练运用它们。

知识面的拓展

OI 中所涉及的知识非常广泛,仅在《NOI 大纲》中列出的知识点就已经能够涉及到好几摞半人高的书堆了。此外,在日常训练的过程中还需要接触到各类国内外的在线资料,这同时需要良好的外语水平。等等。

对我而言,在学习 OI 之余,我还略微了解了一些软件工程相关的知识,写了一些小玩具出来。

社交关系与合作学习

我结识了许多友好的同学,他们都非常优秀。在竞赛学习的过程中,我们经常会相互帮助,互相学习。这种友好的关系使得我们的竞赛旅途更加愉快。

向优秀选手学习

俗话说得好,「人外有人,天外有天」。在学习竞赛的过程中,我时常有机会接触到并认识来自全国乃至全世界的优秀选手。

比自己更强的选手不一定只是对手,更可以成为我们的老师和榜样。从他们身上可以学习到很多独特的思维方式和优秀的解题方法,而这些在自己日常独自训练时是很难接触到的。所以要学会欣赏和学习优秀选手的思路和方法,并从中受益、成长。只有这样,我们才能不断提高自己的水平,成为更好的自己。

竞赛与文化课

平衡与挑战

对于大部分人,竞赛和文化课是不可兼顾的。既然要抽出时间来学习竞赛,那么就必须压缩一些干其他的事情的时间,比如学习文化课。这会导致文化课的学习效果受到影响,然后成绩就不可避免地下滑了。

考试成绩下降之后,班主任和任课老师们自然会有意见。竞赛不是一条捷径,我们学校每年只有那么几个人能够进入省队并在国赛中取得奖牌,其他人则会慢慢地被淘汰下来,这是不可避免的。老师们自然希望我们的文化课成绩要好一些,所以会鼓动甚至要求我们放弃学习竞赛,毕竟相比之下,竞赛的容错率和回报率太低了。

那么如何在竞赛和文化课之间取得一个较好的平衡就成了一个棘手的问题,这个问题各路人马争论至今也没有一个定论,我觉得以后也不会有一个定论,毕竟人和人是不一样的。

重回文化课

在春季赛后,我休息了半天便准备考虑回归文化课学习的事宜。

我先回班找到了各科的任课老师们,向她们说明了我的实际情况。她们表示理解,希望我能够尽快找回状态,回归文化课的学习,因为我已经落下了很多课程的学习进度。

一些能听懂的科目自然也是要回班听一听的,网课讲得显然不如老师好。不能听懂的科目就只能自己看书听网课,一轮复习再回班跟了。

刚退役的时候还是很失落的,也不能专注到文化课的学习上,不过经过后来的慢慢调整,现在情况有转好的迹象。再慢慢观察吧。

感谢

不论结果如何,我能坚持学习竞赛到今天,都少不了来自家长、教练和同学们的鼓励与支持。

我想感谢我的父母,没有他们的支持和鼓励,我不可能坚持到今天。

我想感谢我的教练任亮老师和聂文彬老师,没有他们的指导和帮助,我不可能取得今天的成绩(虽然并不是很出类拔萃)。

我想感谢我的同学们,没有他们的陪伴和帮助,我不可能从竞赛学习中收获如此多的东西。

后记

0

虽然退役了,但是我应该还会经常回来 OI 圈子看一看,没准还会参加一些比赛呢。

一切皆有可能,接下来的日子里,我会继续努力,不断提高自己的水平,成为更好的自己。

1

竞赛不是火,却能点亮一生。

这是石家庄二中实验学校旧信息中心旁的信息学竞赛教室墙外贴的一句话。

这句话的意思是,学习竞赛虽然不会像火焰燃烧那样为当下带来光明与温暖,但是它能够在一个人的一生中产生持久的影响。竞赛可以激发人的竞争精神,并培养毅力和耐力等品质。这些优点不仅在竞赛过程中得到锻炼,而且会伴随一个人的一生,对其产生长远、积极的影响。

上初中时第一次看到这句话时,我便对其留下了深刻的印象。随着时间的推移以及心境的不同,每次看到这句话,我都会对其有不同的理解。直到我的 OI 之旅走到尽头之时,我才明白了这句话之中的深意。

2

在退役之前的一个晚上,我走出实验楼的机房,向旁边的教学楼望了过去。灯火通明的教学楼与人烟稀少的实验楼形成了鲜明的对比 —— 这使得我莫名地产生了一种怅然若失的感觉 —— 我的竞赛之旅即将结束,我将要离开这个我已经熟悉的环境,去面对一个陌生的未来。

我想起了小时候读过的一首诗歌中的内容:

也许多少年后在某个地方,

我将轻声叹息把往事回顾,

一片树林里分出两条路,

而我选择了人迹更少的一条,

因此走出了这迥异的旅途。

– The Road Not Taken, Robert Frost.

我选择了竞赛,一个小众的发展方向,而这个选择决定了我今后的人生道路。竞赛决不是捷径,它只是另一种艰辛的生活方式。我不知道未来的路会怎么走,但我知道,我会一直一步一步脚踏实地地走下去。即使不再参加与竞赛相关的活动,竞赛带给我的思维方式也将伴我一生。

3

【心态乐观】

有人说,“生命中,我们都接到不同的剧本。平淡或浓烈,欢笑或眼泪,我们总要演好,直至落幕。”

心态好,一切都好。积极乐观的心态,是幸福生活的钥匙。

不管发生什么事,记得告诉自己,一切都会过去,好事自会发生。

—— 摘抄:人民日报夜读《善待自己,过张弛有度的生活》,2023 年 02 月 25 日。

4

大家都说,高考是千军万马过独木桥,不容易。

可是又有几个「大家」知道,竞赛是一个人摸黑走路,盲人骑瞎马,半夜临深池?

在无数个孤独清冷的深夜,无数次羡慕已经安然入梦的同学们。

我们都是行走在镜面边缘的人。

低下头看到的,是半个迷茫的自己,和半个不见底的深渊。

到哪里,会不会跌倒,是到终点还是滑进深渊,都不知道。

唯一确定的是,自己只有一个人。

—— 《行走在镜面的边缘》

5

得到与失去,只有时间会去评判;成功与失败,只有历史能去仲裁。

我不会永远成功,正如我不会永远失败一样。

—— 洪骥《……》


本文为原创文章,未经许可禁止任何形式的复制、摘抄与转载。

向 #define int long long 说不

2023-02-08 09:21:05

TL;DR

#define int long long 是一种未定义行为,尽量不要在代码中使用它。

前言

在算法竞赛社区中,经常能看见有人在代码中使用 #define int long long 来偷懒。我是一直极力反对这种做法的,因为这种做法会导致代码的可读性大大降低,并带来一些难以预料的问题。

C++ 标准

在 ISO/IEC 14882:2014(E) 的 17.6.4.3.1 Macro names 一节中,有这样一段描述:

翻译并整理一下,就是:

翻译单元不可 #define#undef 词法上等同于下列部分的名称:

  • C++ 中的关键字(表 4、表 5,在 2.12 节 Keywords [lex.key] 中给出);

  • 有特殊含义的标识符(表 3,在 2.11 节 Identifiers [lex.name] 中给出);

  • 任何标准属性记号(attribute-token,在 7.6 节 Attributes [dcl.attr] 中给出)。

也就是说,标准中 并不允许 #define int 这种操作。

编译器实现

GCC

在 GCC 的 C Preprocessor 文档中 给出了下面的说明:

You may define any valid identifier as a macro, even if it is a C keyword.

也就是说,GCC 并没有严格按照标准来实现预处理器,而是稍微放宽了一些限制以允许通过这种方式来使得代码更加灵活,便于增强代码的向下兼容性。

Clang

相关文档中并未提及是否允许 define 关键字,但源代码中未见相关限制。

MSVC

#define 指令 相关文档中并未提及。

后记

使用适当的数据类型来存储数据,有利于代码的可读性和稳定性,便于编写和调试。同时,正确设置变量类型也能提高程序的运行速度和效率。因此,我们应该做好正确的数据类型定义,而不是在编写代码时滥用 #define int long long