我们阅读别人的代码时,最痛苦的莫过于代码难以阅读和维护。
在这篇文章中,我将分享如何在 Python 中编写干净的代码及代码编写规则。
对于每个原则,我将提供小的代码片段来更好地解释原则,并向你展示如何处理事情以及如何不处理事情。
我希望这篇文章能为所有使用 Python 的人提供价值,但特别是激励其他数据科学家编写干净的代码。
这一部分应该是显而易见的,但许多开发人员仍然没有遵循它。
创建有意义的名称!
每个人在阅读你的代码时都应该直接理解发生了什么。不应该需要内联注释来描述您的代码在做什么以及某些变量代表什么。
如果名称是描述性的,那么应该非常清楚函数在做什么。
让我们看一个典型的机器学习示例:加载数据集并将其拆分为训练集和测试集:
`import pandas as pd from sklearn.model_selection import train_test_split def load_and_split(d): df = pd.read_csv(d) X = df.iloc[:, :-1] y = df.iloc[:, -1] X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) return X_train, X_test, y_train, y_test `
大多数了解数据科学的人都知道这里发生了什么,他们也知道 X 是什么,y 是什么。但是对于新手来说呢?
将 CSV 文件的路径仅用 d 命名,这是一个好的做法吗?
将特征命名为 X,将目标命名为 y,这是一个好的做法吗?
让我们看一个更具有意义的名称的例子:
`import pandas as pd from sklearn.model_selection import train_test_split def load_data_and_split_into_train_test(dataset_path): data_frame = pd.read_csv(dataset_path) features = data_frame.iloc[:, :-1] target = data_frame.iloc[:, -1] features_train, features_test, target_train, target_test = train_test_split(features, target, test_size=0.2, random_state=42) return features_train, features_test, target_train, target_test `
这样更容易理解。现在,即使是对于不熟悉 pandas 和 train_test_split 约定的人,也非常清楚该函数正在从 dataset_path 中列出的路径加载数据,从数据帧中检索特征和目标,然后计算训练集和测试集的特征和目标。
这些更改使代码更易于阅读和理解,特别是对于可能不熟悉机器学习代码约定的人来说,其中特征大多以 X 命名,目标以 y 命名。
但是请不要过度使用不提供任何附加信息的命名。
让我们看另一个例子代码片段:
`import pandas as pd from sklearn.model_selection import train_test_split def load_data_from_csv_and_split_into_training_and_testing_sets(dataset_path_csv): data_frame_from_csv = pd.read_csv(dataset_path_csv) features_columns_data_frame = data_frame_from_csv.iloc[:, :-1] target_column_data_frame = data_frame_from_csv.iloc[:, -1] features_columns_data_frame_for_training, features_columns_data_frame_for_testing, target_column_data_frame_for_training, target_column_data_frame_for_testing = train_test_split(features_columns_data_frame, target_column_data_frame, test_size=0.2, random_state=42) return features_columns_data_frame_for_training, features_columns_data_frame_for_testing, target_column_data_frame_for_training, target_column_data_frame_for_testing `
当你看到这段代码时,你有什么感觉?
有必要包含函数加载 CSV 吗?以及数据集路径是指向 CSV 文件的路径吗?
这段代码包含太多没有提供任何额外信息的信息。它反而使读者分心。
因此,添加有意义的名称是在描述性和简洁之间取得平衡的行为。
现在让我们来看看函数。
函数的第一个规则是它们应该很小。函数的第二个规则是它们应该比那更小 [1]。
这一点非常重要!
函数应该很小,不超过 20 行。如果函数中有大块占用大量空间的代码,请将它们放入新的函数中。
另一个重要原则是函数应该做一件事。而不是更多。如果它们做了更多,请将第二个事情分离到新的函数中。
现在让我们再看一个小例子:
`import pandas as pd from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler def load_clean_feature_engineer_and_split(data_path): # Load data df = pd.read_csv(data_path) # Clean data df.dropna(inplace=True) df = df[df['Age'] > 0] # Feature engineering df['AgeGroup'] = pd.cut(df['Age'], bins=[0, 18, 65, 99], labels=['child', 'adult', 'senior']) df['IsAdult'] = df['Age'] > 18 # Data preprocessing scaler = StandardScaler() df[['Age', 'Fare']] = scaler.fit_transform(df[['Age', 'Fare']]) # Split data features = df.drop('Survived', axis=1) target = df['Survived'] features_train, features_test, target_train, target_test = train_test_split(features, target, test_size=0.2, random_state=42) return features_train, features_test, target_train, target_test `
您能否已经发现上述提到的规则的违规情况?
这个函数不长,但显然违反了一个函数应该做一件事的规则。
此外,注释表明这些代码块可以放在一个单独的函数中,并且可以为每个函数命名,以便更清楚地了解情况,并且不需要注释(关于这一点将在下一节中讨论)。
所以,让我们看看重构后的例子:
`import pandas as pd from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler def load_data(data_path): return pd.read_csv(data_path) def clean_data(df): df.dropna(inplace=True) df = df[df['Age'] > 0] return df def feature_engineering(df): df['AgeGroup'] = pd.cut(df['Age'], bins=[0, 18, 65, 99], labels=['child', 'adult', 'senior']) df['IsAdult'] = df['Age'] > 18 return df def preprocess_features(df): scaler = StandardScaler() df[['Age', 'Fare']] = scaler.fit_transform(df[['Age', 'Fare']]) return df def split_data(df, target_name='Survived'): features = df.drop(target_name, axis=1) target = df[target_name] return train_test_split(features, target, test_size=0.2, random_state=42) if __name__ == "__main__": data_path = 'data.csv' df = load_data(data_path) df = clean_data(df) df = feature_engineering(df) df = preprocess_features(df) X_train, X_test, y_train, y_test = split_data(df) `
在这个重构后的代码片段中,每个函数只做一件事,使得阅读代码变得更容易。测试本身现在也变得更容易,因为每个函数都可以与其他函数隔离地进行测试。
即使注释也不再需要,因为现在函数名称就像是对自己的注释一样。
但是现在还缺少一个部分:文档字符串
文档字符串是 Python 的标准,旨在提供可读和可理解的代码。
每个用于生产代码的函数都应包含一个文档字符串,描述其意图、输入参数以及有关返回值的信息。
文档字符串直接被诸如 Sphinx 这样的工具使用,其目的是为代码创建文档。
现在让我们为上面的代码片段添加文档字符串:
`import pandas as pd from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler def load_data(data_path): """ 从CSV文件中加载数据到pandas DataFrame中。 Args: data_path (str): 数据集的文件路径。 Returns: DataFrame: 加载的数据集。 """ return pd.read_csv(data_path) def clean_data(df): """ 通过删除带有缺失值的行并过滤掉非正年龄的行来清理DataFrame。 Args: df (DataFrame): 输入数据集。 Returns: DataFrame: 清理后的数据集。 """ df.dropna(inplace=True) df = df[df['Age'] > 0] return df def feature_engineering(df): """ 对DataFrame进行特征工程,包括年龄分组和成人标识。 Args: df (DataFrame): 输入数据集。 Returns: DataFrame: 添加了新特征的数据集。 """ df['AgeGroup'] = pd.cut(df['Age'], bins=[0, 18, 65, 99], labels=['child', 'adult', 'senior']) df['IsAdult'] = df['Age'] > 18 return df def preprocess_features(df): """ 通过使用StandardScaler对'Age'和'Fare'列进行标准化来预处理特征。 Args: df (DataFrame): 输入数据集。 Returns: DataFrame: 带有标准化特征的数据集。 """ scaler = StandardScaler() df[['Age', 'Fare']] = scaler.fit_transform(df[['Age', 'Fare']]) return df def split_data(df, target_name='Survived'): """ 将数据集分割为训练集和测试集。 Args: df (DataFrame): 输入数据集。 target_name (str): 目标变量列的名称。 Returns: tuple: 包含训练特征、测试特征、训练目标和测试目标数据集。 """ features = df.drop(target_name, axis=1) target = df[target_name] return train_test_split(features, target, test_size=0.2, random_state=42) if __name__ == "__main__": data_path = 'data.csv' df = load_data(data_path) df = clean_data(df) df = feature_engineering(df) df = preprocess_features(df) X_train, X_test, y_train, y_test = split_data(df) `
集成开发环境(IDEs)如VSCode通常提供文档字符串的扩展,因此只要您在函数定义下添加多行字符串,文档字符串就会自动添加。这有助于您快速获得所需格式的正确文档字符串。
代码主要是阅读的次数要比编写的次数多。没有人愿意阅读格式混乱、难以理解的代码。
在Python中,有PEP 8风格指南可供遵循,以使代码更易读。
一些重要的格式化规则包括:
使用四个空格进行代码缩进
将所有行限制在最多79个字符
在某些情况下避免不必要的空白(即在括号内部,尾随逗号和右括号之间,...) 但请记住:格式化规则应该使代码更易读。有时,应用其中一些规则是没有意义的,因为那样的代码就不会更易读。在这种情况下,请忽略其中一些规则。
您可以使用IDE中的扩展来支持遵循您的指南。例如,VSCode提供了几种用于此目的的扩展。
您可以使用Pylint和autopep8等Python包来支持格式化您的Python脚本。
Pylint是一个静态代码分析器,它会给您的代码打分(最高10分),而autopep8可以自动将您的代码格式化为符合PEP8标准的形式。
让我们使用本文中早期代码片段来了解一下:
`import pandas as pd from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler def load_data(data_path): return pd.read_csv(data_path) def clean_data(df): df.dropna(inplace=True) df = df[df['Age'] > 0] return df def feature_engineering(df): df['AgeGroup'] = pd.cut(df['Age'], bins=[0, 18, 65, 99], labels=['child', 'adult', 'senior']) df['IsAdult'] = df['Age'] > 18 return df def preprocess_features(df): scaler = StandardScaler() df[['Age', 'Fare']] = scaler.fit_transform(df[['Age', 'Fare']]) return df def split_data(df, target_name='Survived'): features = df.drop(target_name, axis=1) target = df[target_name] return train_test_split(features, target, test_size=0.2, random_state=42) if __name__ == "__main__": data_path = 'data.csv' df = load_data(data_path) df = clean_data(df) df = feature_engineering(df) df = preprocess_features(df) X_train, X_test, y_train, y_test = split_data(df) `
现在将其保存到一个名为train.py的文件中,并运行Pylint来检查我们的代码段的分数:
`pylint train.py `
这将产生以下输出:
`************* Module train train.py:29:0: W0311: Bad indentation. Found 2 spaces, expected 4 (bad-indentation) train.py:30:0: W0311: Bad indentation. Found 2 spaces, expected 4 (bad-indentation) train.py:31:0: W0311: Bad indentation. Found 2 spaces, expected 4 (bad-indentation) train.py:32:0: W0311: Bad indentation. Found 2 spaces, expected 4 (bad-indentation) train.py:33:0: W0311: Bad indentation. Found 2 spaces, expected 4 (bad-indentation) train.py:34:0: C0304: Final newline missing (missing-final-newline) train.py:34:0: W0311: Bad indentation. Found 2 spaces, expected 4 (bad-indentation) train.py:1:0: C0114: Missing module docstring (missing-module-docstring) train.py:5:0: C0116: Missing function or method docstring (missing-function-docstring) train.py:5:14: W0621: Redefining name 'data_path' from outer scope (line 29) (redefined-outer-name) train.py:8:0: C0116: Missing function or method docstring (missing-function-docstring) train.py:8:15: W0621: Redefining name 'df' from outer scope (line 30) (redefined-outer-name) train.py:13:0: C0116: Missing function or method docstring (missing-function-docstring) train.py:13:24: W0621: Redefining name 'df' from outer scope (line 30) (redefined-outer-name) train.py:18:0: C0116: Missing function or method docstring (missing-function-docstring) train.py:18:24: W0621: Redefining name 'df' from outer scope (line 30) (redefined-outer-name) train.py:23:0: C0116: Missing function or method docstring (missing-function-docstring) train.py:23:15: W0621: Redefining name 'df' from outer scope (line 30) (redefined-outer-name) train.py:29:2: C0103: Constant name "data_path" doesn't conform to UPPER_CASE naming style (invalid-name) ------------------------------------------------------------------ Your code has been rated at 3.21/10 `
哇,只有3.21分中的得分。
您现在可以手动修复这些问题,然后重新运行它。或者您可以使用autopep8软件包自动解决其中一些问题。
让我们采取第二种方法:
`autopep8 --in-place --aggressive --aggressive train.py `
现在train.py脚本如下所示:
`import pandas as pd from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler def load_data(data_path): return pd.read_csv(data_path) def clean _data(df): df.dropna(inplace=True) df = df[df['Age'] > 0] return df def feature_engineering(df): df['AgeGroup'] = pd.cut( df['Age'], bins=[ 0, 18, 65, 99], labels=[ 'child', 'adult', 'senior']) df['IsAdult'] = df['Age'] > 18 return df def preprocess_features(df): scaler = StandardScaler() df[['Age', 'Fare']] = scaler.fit_transform(df[['Age', 'Fare']]) return df def split_data(df, target_name='Survived'): features = df.drop(target_name, axis=1) target = df[target_name] return train_test_split(features, target, test_size=0.2, random_state=42) if __name__ == "__main__": data_path = 'data.csv' df = load_data(data_path) df = clean_data(df) df = feature_engineering(df) df = preprocess_features(df) X_train, X_test, y_train, y_test = split_data(df) `
再次运行Pylint,我们得到了10分的分数:
`pylint train.py `
太棒了!
这真正展示了Pylint在使您的代码更清晰并迅速遵循PEP8标准方面的作用。
错误处理确保您的代码能够处理意外情况,而不会崩溃或产生不正确的结果。
想象一下,您部署了一个模型在一个API后面,用户可以向该部署模型发送数据。然而,用户可能会向该模型发送错误的数据,因此应用程序可能会崩溃,这对用户体验来说不是一个好印象。他们很可能会责怪您的应用程序,并声称它开发得不好。
如果用户收到一个特定的错误代码和一个清楚告诉他们出了什么问题的消息,那就更好了。
这就是Python异常发挥作用的地方。
假设用户可以上传一个CSV文件到您的应用程序,将其加载到pandas数据框中,然后将其转发给您的模型进行预测。
那么您会有一个类似以下的函数:
`import pandas as pd def load_data(data_path): """ 从指定的CSV文件中加载数据集到pandas DataFrame中。 参数: data_path (str): 数据集的文件路径。 返回: DataFrame: 加载的数据集。 """ return pd.read_csv(data_path) `
到目前为止,一切都很顺利。
但是当用户没有提供CSV文件时会发生什么呢?
您的程序将崩溃,并显示以下错误消息:
`FileNotFoundError: [Errno 2] No such file or directory: 'data.csv' `
由于您正在运行一个API,它将简单地向用户返回一个HTTP 500代码,告诉他有一个“内部服务器错误”。
用户可能会因此责怪您的应用程序,因为他无法看到他对该错误负责。
有什么更好的处理方法吗?
添加一个try-except块并捕获FileNotFoundError来正确处理该情况:
`import pandas as pd import logging def load_data(data_path): """ 从指定的CSV文件中加载数据集到pandas DataFrame中。 参数: data_path (str): 数据集的文件路径。 返回: DataFrame: 加载的数据集。 """ try: return pd.read_csv(data_path) except FileNotFoundError: logging.error("路径 %s 处的文件不存在。请确保您已正确上传文件。", data_path) `
但现在我们只记录了该错误消息。最好定义一个自定义异常,然后在我们的API中处理该异常,以向用户返回特定的错误代码:
`import pandas as pd import logging class DataLoadError(Exception): """当数据无法加载时引发的异常。""" def __init__(self, message="数据无法加载"): self.message = message super().__init__(self.message) def load_data(data_path): """ 从指定的CSV文件中加载数据集到pandas DataFrame中。 参数: data_path (str): 数据集的文件路径。 返回: DataFrame: 加载的数据集。 """ try: return pd.read_csv(data_path) except FileNotFoundError: logging.error("路径 %s 处的文件不存在。请确保您已正确上传文件。", data_path) raise DataLoadError(f"路径 {data_path} 处的文件不存在。请确保您已正确上传文件。") `
然后,在您的API的主要函数中:
`try: df = load_data('path/to/data.csv') # 进行进一步的处理和模型预测 except DataLoadError as e: # 向用户返回一个包含错误消息的响应 # 例如:return Response({"error": str(e)}, status=400) `
现在,用户将收到一个错误代码400(错误的请求),并带有一个告诉他们出了什么问题的错误消息。
他现在知道该怎么做了,不会再责怪您的程序无法正常工作了。
面向对象编程是一种编程范式,它提供了一种将属性和行为捆绑到单个对象中的方法。
主要优点:
对象通过封装隐藏数据。
通过继承可以重用代码。
可以将复杂问题分解为小对象,并且开发人员可以一次专注于一个对象。
提高可读性。
还有许多其他优点。我强调了最重要的几个(至少对我来说是这样)。
现在让我们看一个小例子,其中创建了一个名为“TrainingPipeline”的类,并带有一些基本函数:
`from abc import ABC, abstractmethod class TrainingPipeline(ABC): def __init__(self, data_path, target_name): """ 初始化TrainingPipeline。 Args: data_path (str): 数据集的文件路径。 target_name (str): 目标列的名称。 """ self.data_path = data_path self.target_name = target_name self.data = None self.X_train = None self.X_test = None self.y_train = None self.y_test = None @abstractmethod def load_data(self): """从数据路径加载数据集。""" pass @abstractmethod def clean_data(self): """清理数据。""" pass @abstractmethod def feature_engineering(self): """执行特征工程。""" pass @abstractmethod def preprocess_features(self): """预处理特征。""" pass @abstractmethod def split_data(self): """将数据拆分为训练集和测试集。""" pass def run(self): """运行训练管道。""" self.load_data() self.clean_data() self.feature_engineering() self.preprocess_features() self.split_data() `
这是一个抽象基类,仅定义了派生自基类的类必须实现的抽象方法。
这在定义所有子类都必须遵循的蓝图或模板时非常有用。
然后,一个示例子类可能如下所示:
`import pandas as pd from sklearn.preprocessing import StandardScaler class ChurnPredictionTrainPipeline(TrainingPipeline): def load_data(self): """从数据路径加载数据集。""" self.data = pd.read_csv(self.data_path) def clean_data(self): """清理数据。""" self.data.dropna(inplace=True) def feature_engineering(self): """执行特征工程。""" categorical_cols = self.data.select_dtypes(include=['object', 'category']).columns self.data = pd.get_dummies(self.data, columns=categorical_cols, drop_first=True) def preprocess_features(self): """预处理特征。""" numerical_cols = self.data.select_dtypes(include=['int64', 'float64']).columns scaler = StandardScaler() self.data[numerical_cols] = scaler.fit_transform(self.data[numerical_cols]) def split_data(self): """将数据拆分为训练集和测试集。""" features = self.data.drop(self.target_name, axis=1) target = self.data[self.target_name] self.features_train, self.features_test, self.target_train, self.target_test = train_test_split(features, target, test_size=0.2, random_state=42) `
这样做的好处是,您可以构建一个应用程序,该应用程序自动调用训练管道的方法,并且可以创建训练管道的不同类。它们始终兼容,并且必须遵循抽象基类中定义的蓝图。
这一章是最重要的章节之一。
有了测试,可以决定整个项目的成功或失败。
创建没有测试的代码更快,因为当您还需要为每个函数编写单元测试时,这似乎是一种“浪费时间”的行为。编写单元测试的代码很快就会超过函数的代码量。
但请相信我,这是值得的努力!
如果您不快速添加单元测试,就会感到痛苦。有时,并不是在一开始就会感到痛苦。
但是当您的代码库增长并且添加更多功能时,您肯定会感到痛苦。突然之间,调整一个函数的代码可能会导致其他函数失败。新的发布需要大量的紧急修复。客户感到恼火。团队中的开发人员害怕适应代码库中的任何东西,导致发布新功能的速度非常缓慢。
因此,无论何时您在编写后续需要投入生产的代码时,请始终遵循测试驱动开发(TDD)原则!
在Python中,可以使用类似unittest或pytest的库来测试您的函数。
我个人更喜欢pytest。
您可以在这篇文章中了解有关Python测试的更多信息。该文章还侧重于集成测试,这是测试的另一个重要方面,以确保您的系统端到端正常工作。
让我们再次看一下之前章节中的ChurnPredictionTrainPipeline类:
`import pandas as pd from sklearn.preprocessing import StandardScaler class ChurnPredictionTrainPipeline(TrainingPipeline): def load_data(self): """Load dataset from data path.""" self.data = pd.read_csv(self.data_path) ... `
现在,让我们使用pytest为加载数据添加单元测试:
`import os import shutil import logging from unittest.mock import patch import joblib import pytest import numpy as np import pandas as pd from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split from sklearn.ensemble import RandomForestClassifier from sklearn.linear_model import LogisticRegression from churn_library import ChurnPredictor @pytest.fixture def path(): """ Return the path to the test csv data file. """ return r"./data/bank_data.csv" def test_import_data_returns_dataframe(path): """ Test that import data can load the CSV file into a pandas dataframe. """ churn_predictor = ChurnPredictionTrainPipeline(path, "Churn") churn_predictor.load_data() assert isinstance(churn_predictor.data, pd.DataFrame) def test_import_data_raises_exception(): """ Test that exception of "FileNotFoundError" gets raised in case the CSV file does not exist. """ with pytest.raises(FileNotFoundError): churn_predictor = ChurnPredictionTrainPipeline("non_existent_file.csv", "Churn") churn_predictor.load_data() def test_import_data_reads_csv(path): """ Test that the pandas.read_csv function gets called. """ with patch("pandas.read_csv") as mock_csv: churn_predictor = ChurnPredictionTrainPipeline(path, "Churn") churn_predictor.load_data() mock_csv.assert_called_once_with(path) `
这些单元测试是:
测试CSV文件是否可以加载到pandas数据框中。
测试在CSV文件不存在的情况下是否会引发FileNotFoundError异常。
测试pandas的“read_csv”函数是否被调用。
这个过程并不完全是TDD,因为在添加单元测试之前,我已经开发了代码。但在理想情况下,您应该在实现load_data函数之前甚至编写这些单元测试。
你会一次性建造一座城市吗?很可能不会。
对软件也是一样的。
构建一个干净的系统就是将其拆分为更小的组件。每个组件都使用清晰的代码原则构建,并经过良好的测试。
这一章中最重要的部分是关注关注点的分离:
将启动过程与运行时逻辑分开,构造依赖项。在主函数中初始化所有对象,并将它们插入到依赖它们的类中(依赖注入)。这种方法有助于逐步构建系统,使其易于扩展和添加更多功能。
并发有时对于通过巧妙地在任务之间跳转来加快进程速度是有帮助的。
并发也可以被看作是一种解耦策略,因为不同的部分需要独立运行,以便并发可以提高总体运行时间。
并发也会带来一些开销,并使程序更加复杂,因此明智地决定是否值得投入这样的工作。
例如,您需要处理共享资源和同步访问。
在Python中,您可以利用 asyncio 模块。在这篇文章中阅读更多关于Python中并发的内容。
重构您的代码可以提高可读性和可维护性。
总是从简单开始,甚至是从丑陋的代码开始。让它运行起来。然后进行重构。消除重复,改进命名,并降低复杂性。
但请记住,在开始重构之前一定要有您的测试。这样可以确保在重构时不会破坏东西。
您应该重构您的代码使其更加清晰。太多的开发人员,包括我开始时,都有这样的想法,即我的代码现在可以运行,所以我推送它然后继续下一个任务。
摆脱这种思维方式!否则,随着代码库的增长,您将会遇到很多问题,现在您必须处理难以维护的丑陋代码。
编写清洁的代码是一门艺术。它需要纪律性,并且经常不够。但它对于软件项目的成功非常重要。
作为一名数据科学家,您往往不会编写干净的代码,因为您主要专注于寻找好的模型并在Jupyter Notebooks中运行代码以获得您所追求的指标。
当我主要从事数据科学项目时,我也从不关心编写干净的代码。
但是,数据科学家编写干净的代码对于确保模型更快地投入生产也是至关重要的。
更多每日开发小技巧
尽在****未闻 Code Telegram Channel !
END
未闻 Code·知识星球开放啦!
一对一答疑爬虫相关问题
职业生涯咨询
面试经验分享
每周直播分享
......
未闻 Code·知识星球期待与你相见~
一二线大厂在职员工
十多年码龄的编程老鸟
国内外高校在读学生
中小学刚刚入门的新人
在“未闻 Code技术交流群”等你来!
入群方式:添加微信“mekingname”,备注“粉丝群”(谢绝广告党,非诚勿扰!)